diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c19bd4..4ae176d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,3 +12,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- feat: add shared framework-neutral normalization and presentation helpers for debug adapters.
- feat: add validated collector contracts, lifecycle coordination, and isolated typed snapshot capture for adapters.
- fix: guard the user-switch panel against concurrent submits so a double-click (or set + reset in the same tick) sends a single identity-switch request instead of racing two session regenerations.
+- test: strengthen mutation coverage and clear PHPStan's result cache before static mutation analysis.
diff --git a/composer.json b/composer.json
index 5f8c8e8..ef96aa8 100644
--- a/composer.json
+++ b/composer.json
@@ -24,7 +24,9 @@
"ext-intl": "*",
"ext-mbstring": "*",
"ui-awesome/html": "^0.6",
+ "ui-awesome/html-core-component": "^0.4",
"ui-awesome/html-helper": "^0.7",
+ "ui-awesome/html-interop": "^0.4",
"ui-awesome/html-svg": "^0.6"
},
"require-dev": {
@@ -73,6 +75,7 @@
"mutation": "php -d memory_limit=-1 vendor/bin/infection --threads=4 --ignore-msi-with-no-mutations --min-msi=100 --min-covered-msi=100",
"mutation-static": [
"Composer\\Config::disableProcessTimeout",
+ "./vendor/bin/phpstan clear-result-cache",
"php -d memory_limit=-1 vendor/bin/infection --threads=4 --ignore-msi-with-no-mutations --min-msi=100 --min-covered-msi=100 --static-analysis-tool=phpstan --static-analysis-tool-options='--memory-limit=-1'"
],
"rector": "./vendor/bin/rector process",
diff --git a/resources/views/history.php b/resources/views/history.php
deleted file mode 100644
index e2e7a40..0000000
--- a/resources/views/history.php
+++ /dev/null
@@ -1,93 +0,0 @@
- $rows Normalized request history rows.
- */
-
-$heading = H1::tag()->class('yii-debug-sr-only')->content('Request history');
-$summary = Header::tag()
- ->class('yii-debug-grid-summary')
- ->html(
- Span::tag()->html(
- Strong::tag()->content((string) count($rows)),
- ' Requests',
- ),
- );
-
-if ($rows === []) {
- $history = Div::tag()->class('yii-debug-empty-state')->content('No debug snapshots are available.');
-} else {
- $body = Tbody::tag();
-
- foreach ($rows as $row) {
- $body = $body->tr(
- Tr::tag()
- ->addDataAttribute('yii-debug-tag', $row['tag'])
- ->html(
- Td::tag()->html(
- A::tag()
- ->class('yii-debug-tag-link')
- ->content($row['tag'])
- ->href($row['viewUrl']),
- ),
- Td::tag()->html(
- Span::tag()
- ->class('yii-debug-method yii-debug-verb-' . $row['methodVariant'])
- ->content($row['method']),
- ),
- Td::tag()->html(
- Span::tag()
- ->class('yii-debug-badge yii-debug-status-' . $row['statusVariant'])
- ->content((string) $row['statusCode']),
- ),
- Td::tag()->content($row['duration']),
- Td::tag()->content($row['memory']),
- Td::tag()->class('yii-debug-cell-nowrap')->content($row['capturedAt']),
- Td::tag()->html(
- A::tag()
- ->class('yii-debug-url-cell')
- ->content($row['requestUrl'])
- ->href($row['viewUrl'])
- ->title($row['requestUrl']),
- ),
- ),
- );
- }
-
- $history = Div::tag()
- ->class('yii-debug-table-wrap yii-debug-grid-history')
- ->html(
- Table::tag()
- ->class('yii-debug-table')
- ->thead(
- Thead::tag()->tr(
- Tr::tag()->headerCells('ID', 'Method', 'Status', 'Duration', 'Memory', 'Captured', 'URL'),
- ),
- )
- ->tbody($body),
- );
-}
-?>
-= $heading->render() ?>
-= $summary->render() ?>
-= $history->render();
diff --git a/resources/views/sidebar.php b/resources/views/sidebar.php
deleted file mode 100644
index d2c2c55..0000000
--- a/resources/views/sidebar.php
+++ /dev/null
@@ -1,89 +0,0 @@
- $navigation Panel navigation data.
- */
-
-$snapshotMeta = Div::tag()->class('yii-debug-snapshot-meta');
-
-if ($snapshot['statusCode'] !== null) {
- $snapshotMeta = $snapshotMeta->html(
- Span::tag()
- ->class('yii-debug-snapshot-status yii-debug-status-' . $snapshot['statusVariant'])
- ->content((string) $snapshot['statusCode']),
- );
-}
-
-if ($snapshot['time'] !== '') {
- $snapshotMeta = $snapshotMeta->html(Span::tag()->content($snapshot['time']));
-}
-
-if ($snapshot['ajax']) {
- $snapshotMeta = $snapshotMeta->html(Span::tag()->class('yii-debug-snapshot-tag')->content('AJAX'));
-}
-
-$snapshotCard = Div::tag()
- ->class('yii-debug-history-card')
- ->html(
- Div::tag()
- ->class('yii-debug-snapshot-line')
- ->html(
- Span::tag()
- ->class('yii-debug-snapshot-method yii-debug-verb-' . $snapshot['methodVariant'])
- ->content($snapshot['method']),
- Span::tag()->class('yii-debug-snapshot-url')->content($snapshot['url']),
- ),
- $snapshotMeta,
- )
- ->title(trim($snapshot['method'] . ' ' . $snapshot['url']));
-$navigationList = Ul::tag();
-
-foreach ($navigation as $item) {
- $link = A::tag()
- ->addAriaAttribute('current', $item['active'] ? 'page' : null)
- ->class('yii-debug-nav-link' . ($item['active'] ? ' is-active' : ''))
- ->href($item['url'])
- ->html(
- Span::tag()
- ->addAriaAttribute('hidden', 'true')
- ->class('yii-debug-nav-link-icon')
- ->html($item['icon']),
- Span::tag()->class('yii-debug-nav-link-label')->content($item['label']),
- );
- $navigationList = $navigationList->html(Li::tag()->html($link));
-}
-
-$sidebar = Aside::tag()
- ->class('yii-debug-sidebar')
- ->html(
- Section::tag()
- ->class('yii-debug-side-section')
- ->html(
- Span::tag()->class('yii-debug-side-section-title')->content($snapshot['title']),
- $snapshotCard,
- ),
- Nav::tag()
- ->addAriaAttribute('label', 'Debugger panels')
- ->class('yii-debug-nav yii-debug-nav-iconed')
- ->html($navigationList),
- );
-?>
-= $sidebar->render();
diff --git a/src/Helper/Dump.php b/src/Helper/Dump.php
index 6cc8239..30454a8 100644
--- a/src/Helper/Dump.php
+++ b/src/Helper/Dump.php
@@ -10,6 +10,7 @@
use function get_debug_type;
use function gettype;
use function is_array;
+use function is_scalar;
use function range;
use function str_repeat;
use function var_export;
@@ -79,7 +80,7 @@ private static function dumpArray(array $value, int $depth, int $level): string
foreach (array_keys($value) as $key) {
$output .= "\n{$spaces} ";
- $output .= self::dumpInternal($key, $depth, 0);
+ $output .= self::dumpInternal($key, $depth, $level);
$output .= ' => ';
$output .= self::dumpInternal($value[$key], $depth, $level + 1);
}
@@ -126,7 +127,7 @@ private static function exportInternal(mixed $value, int $level): string
$output .= "\n{$spaces} ";
if ($outputKeys) {
- $output .= self::exportInternal($key, 0);
+ $output .= self::exportInternal($key, $level);
$output .= ' => ';
}
diff --git a/src/Panel/Asset/AssetCardRenderer.php b/src/Panel/Asset/AssetCardRenderer.php
index 3c8c518..c5efd2f 100644
--- a/src/Panel/Asset/AssetCardRenderer.php
+++ b/src/Panel/Asset/AssetCardRenderer.php
@@ -51,21 +51,13 @@ public static function renderCard(AssetBundleView $bundle, AssetSummary $summary
/**
* Resolves the anchor id for a dependency name.
*
- * Prefers the id of an already-registered bundle so cross-references jump to a real card; otherwise falls back to a
- * fresh {@see \\PHPForge\\Debug\\Helper\\Text::camel2id()} pass, so the link still points to the id the bundle
- * would get if it were registered later.
+ * Uses the same canonical {@see \\PHPForge\\Debug\\Helper\\Text::camel2id()} conversion as bundle registration.
*
* @param string $depName Fully qualified class name of the dependency.
- * @param AssetSummary $summary Already-normalized summary.
+ * @param AssetSummary $summary Already-normalized summary, retained for backward compatibility.
*/
public static function resolveAnchor(string $depName, AssetSummary $summary): string
{
- foreach ($summary->bundles as $candidate) {
- if ($candidate->name === $depName) {
- return $candidate->id;
- }
- }
-
return Text::camel2id($depName);
}
diff --git a/src/Panel/Db/DbQueryRenderer.php b/src/Panel/Db/DbQueryRenderer.php
index 4559df0..7288190 100644
--- a/src/Panel/Db/DbQueryRenderer.php
+++ b/src/Panel/Db/DbQueryRenderer.php
@@ -15,8 +15,8 @@
use function date;
use function implode;
use function in_array;
-use function mb_strtoupper;
use function sprintf;
+use function strtoupper;
/**
* Renders the typed cells of the queries grid for the DB debug panel.
@@ -41,7 +41,7 @@ final class DbQueryRenderer
public static function canBeExplained(string $type): bool
{
return in_array(
- mb_strtoupper($type, 'utf8'),
+ strtoupper($type),
['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'WITH'],
true,
);
diff --git a/src/Panel/Dump/DumpCardRenderer.php b/src/Panel/Dump/DumpCardRenderer.php
index 24911ea..56fad07 100644
--- a/src/Panel/Dump/DumpCardRenderer.php
+++ b/src/Panel/Dump/DumpCardRenderer.php
@@ -14,9 +14,9 @@
use function array_map;
use function basename;
use function date;
-use function floor;
use function html_entity_decode;
use function in_array;
+use function intval;
use function is_int;
use function ltrim;
use function preg_match;
@@ -77,7 +77,7 @@ private static function formatTime(float $time): string
return '';
}
- $millis = (int) (($time - floor($time)) * 1000);
+ $millis = intval($time * 1000) % 1000;
return date('H:i:s', (int) $time) . '.' . sprintf('%03d', $millis);
}
@@ -199,8 +199,8 @@ private static function sniffType(string $message): array
return ['string', 'string'];
}
- if (preg_match('/^([A-Za-z_][A-Za-z0-9_\\\\]*)/', $payload, $m) === 1) {
- $name = $m[1];
+ if (preg_match('/^[A-Za-z_][A-Za-z0-9_\\\\]*/', $payload, $m) === 1) {
+ $name = $m[0];
$lower = strtolower($name);
diff --git a/src/Panel/Log/LogCellRenderer.php b/src/Panel/Log/LogCellRenderer.php
index b632f64..8e8fc67 100644
--- a/src/Panel/Log/LogCellRenderer.php
+++ b/src/Panel/Log/LogCellRenderer.php
@@ -16,6 +16,7 @@
use function array_map;
use function date;
use function implode;
+use function intdiv;
use function sprintf;
use function str_starts_with;
@@ -119,11 +120,11 @@ public static function renderMessageCell(LogRow $row, Closure $traceLine): strin
*/
public static function renderTimeCell(LogRow $row): string
{
- $seconds = $row->time / 1000;
+ $timestamp = (int) $row->time;
+ $seconds = intdiv($timestamp, 1000);
+ $millis = $timestamp % 1000;
- $millis = (int) (($seconds - (int) $seconds) * 1000);
-
- return date('H:i:s.', (int) $seconds) . sprintf('%03d', $millis);
+ return date('H:i:s.', $seconds) . sprintf('%03d', $millis);
}
/**
@@ -133,15 +134,13 @@ public static function renderTimeCell(LogRow $row): string
*/
public static function renderTimeSincePreviousCell(LogRow $row): string
{
- $diffMsTotal = $row->time - $row->timeOfPrevious;
-
- $diffSecondsTotal = $diffMsTotal / 1000;
- $diffMinutesTotal = $diffSecondsTotal / 60;
- $diffHoursTotal = $diffMinutesTotal / 60;
- $diffMs = (int) $diffMsTotal % 1000;
- $diffSeconds = (int) $diffSecondsTotal % 60;
- $diffMinutes = (int) $diffMinutesTotal % 60;
- $diffHours = (int) $diffHoursTotal;
+ $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;
$parts = [];
diff --git a/src/Panel/Mail/MailCardRenderer.php b/src/Panel/Mail/MailCardRenderer.php
index 9081904..0a41b15 100644
--- a/src/Panel/Mail/MailCardRenderer.php
+++ b/src/Panel/Mail/MailCardRenderer.php
@@ -17,12 +17,11 @@
use function array_map;
use function date;
use function explode;
-use function floor;
+use function intdiv;
use function mb_strlen;
use function mb_strtoupper;
use function mb_substr;
use function preg_replace;
-use function time;
/**
* Renders the typed mail message card consumed by the Mail panel detail view's `_item` template.
@@ -104,19 +103,19 @@ private static function formatTime(int $unix): array
}
if ($diff < 3600) {
- $minutes = (int) floor($diff / 60);
+ $minutes = intdiv($diff, 60);
return ["{$minutes} min ago", $absolute];
}
if ($diff < 86400) {
- $hours = (int) floor($diff / 3600);
+ $hours = intdiv($diff, 3600);
return ["{$hours} h ago", $absolute];
}
if ($diff < 2592000) {
- $days = (int) floor($diff / 86400);
+ $days = intdiv($diff, 86400);
return ["{$days} d ago", $absolute];
}
diff --git a/src/Panel/Queue/QueueGridRenderer.php b/src/Panel/Queue/QueueGridRenderer.php
index 8a6f545..de57944 100644
--- a/src/Panel/Queue/QueueGridRenderer.php
+++ b/src/Panel/Queue/QueueGridRenderer.php
@@ -11,6 +11,7 @@
use function abs;
use function date;
+use function intval;
use function number_format;
use function sprintf;
@@ -143,8 +144,8 @@ public static function renderStatusCell(JobRecord $record): string
*/
public static function renderTimeCell(JobRecord $record): string
{
- $seconds = (int) $record->time;
- $milliseconds = abs((int) (($record->time - $seconds) * 1000));
+ $seconds = intval($record->time);
+ $milliseconds = abs(intval($record->time * 1000) % 1000);
return date('H:i:s.', $seconds) . sprintf('%03d', $milliseconds);
}
diff --git a/src/Panel/User/UserDataNormalizer.php b/src/Panel/User/UserDataNormalizer.php
index 2420f1c..1051b65 100644
--- a/src/Panel/User/UserDataNormalizer.php
+++ b/src/Panel/User/UserDataNormalizer.php
@@ -8,8 +8,8 @@
use function ctype_digit;
use function date;
-use function floor;
use function in_array;
+use function intdiv;
use function is_array;
use function mb_strtoupper;
use function mb_substr;
@@ -19,7 +19,6 @@
use function str_starts_with;
use function strlen;
use function substr;
-use function time;
use function trim;
use function ucwords;
@@ -94,7 +93,7 @@ private static function buildAttributes(array $bucket, array $labels, string $ki
foreach ($bucket as $key => $value) {
$display = self::stripQuotes($value);
- $isEmpty = $display === '' || $value === 'null';
+ $isEmpty = $display === '';
if ($isEmpty) {
$rows[] = new UserAttribute(
@@ -280,15 +279,15 @@ private static function humanTime(string $value): array
if ($diff < 60) {
$relative = 'just now';
} elseif ($diff < 3600) {
- $minutes = floor($diff / 60);
+ $minutes = intdiv($diff, 60);
$relative = "{$minutes} min ago";
} elseif ($diff < 86400) {
- $hours = floor($diff / 3600);
+ $hours = intdiv($diff, 3600);
$relative = "{$hours} h ago";
} elseif ($diff < 2592000) {
- $days = floor($diff / 86400);
+ $days = intdiv($diff, 86400);
$relative = "{$days} d ago";
} else {
@@ -355,11 +354,11 @@ private static function resolveStatus(string $value): array
*/
private static function stripQuotes(string $value): string
{
- if ($value === 'null' || $value === '') {
+ if ($value === 'null') {
return '';
}
- if (str_starts_with($value, "'") && str_ends_with($value, "'") && strlen($value) > 1) {
+ if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
return substr($value, 1, -1);
}
diff --git a/src/PhpInfo/PhpInfoDataNormalizer.php b/src/PhpInfo/PhpInfoDataNormalizer.php
index f5115d4..851fc18 100644
--- a/src/PhpInfo/PhpInfoDataNormalizer.php
+++ b/src/PhpInfo/PhpInfoDataNormalizer.php
@@ -20,8 +20,6 @@
use function is_string;
use function mb_strlen;
use function php_uname;
-use function posix_getpwuid;
-use function posix_getuid;
use function preg_match;
use function preg_match_all;
use function preg_quote;
@@ -32,7 +30,6 @@
use function str_contains;
use function str_starts_with;
use function strip_tags;
-use function stripos;
use function strlen;
use function strpos;
use function strtolower;
@@ -64,7 +61,8 @@ final class PhpInfoDataNormalizer
/**
* Matches the key cell of a phpinfo row (`class="e"`), whether it is still a `
`) anchored at the start of a table body.
*/
@@ -86,7 +84,7 @@ final class PhpInfoDataNormalizer
/**
* Matches a `` or ` ` and captures its content.
*/
- private const string TABLE_CELL_PATTERN = '%<(?:th|td)\b[^>]*>(.*?)(?:th|td)>%si';
+ private const string TABLE_CELL_PATTERN = '%<(?:th|td)\b[^>]*>(?.*?)(?:th|td)>%si';
/**
* Free-form key/value table ('Variable | Value', statistics, contributor lists).
*/
@@ -106,11 +104,11 @@ final class PhpInfoDataNormalizer
/**
* Matches a `` and captures its body.
*/
- private const string TABLE_PATTERN = '%%si';
+ private const string TABLE_PATTERN = '%%si';
/**
* Matches a `` and captures its attributes and its content.
*/
- private const string TABLE_ROW_PATTERN = '% ]*)>(.*?) %si';
+ private const string TABLE_ROW_PATTERN = '%[^>]*)>(?.*?) %si';
/**
* Captures the {@see phpinfo()} report of the current process and narrows it into the typed {@see PhpInfoView}.
@@ -189,7 +187,7 @@ public static function fromOutput(
*/
private static function addRowClass(string $attributes, string $class): string
{
- $withoutClass = (string) preg_replace(self::CLASS_ATTRIBUTE_PATTERN, '', trim($attributes));
+ $withoutClass = preg_replace(self::CLASS_ATTRIBUTE_PATTERN, '', trim($attributes));
return trim("{$withoutClass} class=\"{$class}\"");
}
@@ -383,16 +381,19 @@ private static function buildTile(string $label, string $value, string $home): P
if (str_contains($value, ',')) {
$tokens = self::splitTrimmed($value, ',');
- $isTokenList = count($tokens) > 1;
- foreach ($tokens as $t) {
- if ($t === '' || preg_match('/\s/', $t) === 1 || mb_strlen($t) > 32) {
- $isTokenList = false;
- break;
+ if (count($tokens) > 1) {
+ foreach ($tokens as $t) {
+ if (preg_match('/\s/', $t) === 1 || mb_strlen($t) > 32) {
+ return new PhpInfoTile(
+ label: $label,
+ displayValue: $value,
+ rawValue: $value,
+ kind: PhpInfoTile::KIND_TEXT,
+ );
+ }
}
- }
- if ($isTokenList) {
$tokenDtos = array_map(
static fn(string $t): PhpInfoToken => new PhpInfoToken(label: $t),
$tokens,
@@ -490,11 +491,11 @@ private static function countRowsWithValues(string $tableBody, bool $skipHeaderR
$count = 0;
foreach ($rows as $row) {
- if ($skipHeaderRows && preg_match('/\bclass="[^"]*\bh\b[^"]*"/i', $row[1]) === 1) {
+ if ($skipHeaderRows && preg_match('/\bclass="[^"]*\bh\b[^"]*"/i', $row['attributes']) === 1) {
continue;
}
- preg_match_all('%<(?:th|td)\b[^>]*>%i', $row[2], $cells);
+ preg_match_all('%<(?:th|td)\b[^>]*>%i', $row['body'], $cells);
if (count($cells[0]) >= 2) {
$count++;
@@ -550,23 +551,23 @@ private static function extractCompactModule(
preg_match_all(self::TABLE_PATTERN, $moduleBody, $tables, PREG_SET_ORDER);
- if (count($tables) !== 1 || self::classifyModuleTable($tables[0][1]) !== self::TABLE_KIND_FACTS) {
+ if (count($tables) !== 1 || self::classifyModuleTable($tables[0]['body']) !== self::TABLE_KIND_FACTS) {
return null;
}
- preg_match_all('%]*>(.*?) %si', $tables[0][1], $rows, PREG_SET_ORDER);
+ preg_match_all(self::TABLE_ROW_PATTERN, $tables[0]['body'], $rows, PREG_SET_ORDER);
$tiles = [];
foreach ($rows as $row) {
- preg_match_all(self::TABLE_CELL_PATTERN, $row[1], $cells, PREG_SET_ORDER);
+ preg_match_all(self::TABLE_CELL_PATTERN, $row['body'], $cells, PREG_SET_ORDER);
if (count($cells) < 2) {
continue;
}
- $label = self::decodeCell($cells[0][1]);
- $value = self::decodeCell($cells[1][1]);
+ $label = self::decodeCell($cells[0]['body']);
+ $value = self::decodeCell($cells[1]['body']);
if ($label === '' || $value === '') {
return null;
@@ -595,13 +596,19 @@ private static function extractCompactModule(
*/
private static function extractFirstHeaderCells(string $tableBody): array
{
- if (preg_match('%]*class="[^"]*\bh\b[^"]*"[^>]*>(.*?) %si', $tableBody, $row) !== 1) {
+ if (
+ preg_match(
+ '%]*class="[^"]*\bh\b[^"]*"[^>]*>(?.*?) %si',
+ $tableBody,
+ $row,
+ ) !== 1
+ ) {
return [];
}
- preg_match_all(self::TABLE_CELL_PATTERN, $row[1], $cells, PREG_SET_ORDER);
+ preg_match_all(self::TABLE_CELL_PATTERN, $row['body'], $cells, PREG_SET_ORDER);
- return array_map(static fn(array $cell): string => self::decodeCell($cell[1]), $cells);
+ return array_map(static fn(array $cell): string => self::decodeCell($cell['body']), $cells);
}
/**
@@ -615,7 +622,7 @@ private static function extractKeyCell(string $rowContent): string|null
return null;
}
- return self::decodeCell($keyCell[1]);
+ return self::decodeCell($keyCell['body']);
}
/**
@@ -668,7 +675,7 @@ private static function moduleBodyHasContent(string $moduleBody): bool
preg_match_all(self::TABLE_PATTERN, $moduleBody, $tables, PREG_SET_ORDER);
foreach ($tables as $table) {
- $tableBody = $table[1];
+ $tableBody = $table['body'];
if (self::extractTableTitle($tableBody) !== '') {
$tableBody = self::stripHeaderRow($tableBody);
@@ -726,22 +733,22 @@ private static function normalizeFactRows(string $tableBody): string
$normalized = preg_replace_callback(
self::TABLE_ROW_PATTERN,
static function (array $row): string {
- preg_match_all(self::TABLE_CELL_PATTERN, $row[2], $cells, PREG_SET_ORDER);
+ preg_match_all(self::TABLE_CELL_PATTERN, $row['body'], $cells, PREG_SET_ORDER);
if (count($cells) === 1) {
- $content = trim($cells[0][1]);
- $attributes = self::addRowClass($row[1], 'yii-debug-phpinfo-fact-subheading');
+ $content = trim($cells[0]['body']);
+ $attributes = self::addRowClass($row['attributes'], 'yii-debug-phpinfo-fact-subheading');
return sprintf('%s ', $attributes, $content);
}
- $value = isset($cells[1]) ? self::decodeCell($cells[1][1]) : '';
+ $value = isset($cells[1]) ? self::decodeCell($cells[1]['body']) : '';
$class = mb_strlen($value) > 72
? 'yii-debug-phpinfo-fact yii-debug-phpinfo-fact-wide'
: 'yii-debug-phpinfo-fact';
- $attributes = self::addRowClass($row[1], $class);
+ $attributes = self::addRowClass($row['attributes'], $class);
- return '' . self::renderFactStatusPills($row[2]) . ' ';
+ return '' . self::renderFactStatusPills($row['body']) . ' ';
},
$tableBody,
);
@@ -756,13 +763,13 @@ static function (array $row): string {
* @param string $tableBody Inner HTML of the table.
* @param string $labelOverride Label replacing the inferred one; empty string to keep the inferred label.
* @param bool $collapsible Whether the chrome is a `` disclosure instead of a plain ``.
- * @param bool $open Whether the disclosure starts expanded; ignored when `$collapsible` is `false`.
+ * @param bool|null $open Whether the disclosure starts expanded; `null` for non-collapsible tables.
*/
private static function normalizeModuleTable(
string $tableBody,
- string $labelOverride = '',
- bool $collapsible = false,
- bool $open = false,
+ string $labelOverride,
+ bool $collapsible,
+ bool|null $open,
): string {
$tableTitle = self::extractTableTitle($tableBody);
@@ -787,11 +794,12 @@ private static function normalizeModuleTable(
$encodedLabel = Encode::content($label);
$headTag = $collapsible ? 'summary' : 'header';
$containerTag = $collapsible ? 'details' : 'div';
+ $isOpen = $open === true;
$attributes = $collapsible
? sprintf(
' data-yii-debug-phpinfo-collapsible="true" data-yii-debug-phpinfo-default-open="%s"%s',
- $open ? 'true' : 'false',
- $open ? ' open' : '',
+ $isOpen ? 'true' : 'false',
+ $isOpen ? ' open' : '',
)
: '';
@@ -840,13 +848,13 @@ private static function normalizeVariableTables(string $tableBody): string|null
];
foreach ($rows as $row) {
- if (preg_match('/\bclass="[^"]*\bh\b[^"]*"/i', $row[1]) === 1) {
+ if (preg_match('/\bclass="[^"]*\bh\b[^"]*"/i', $row['attributes']) === 1) {
$header = $row[0];
continue;
}
- $key = self::extractKeyCell($row[2]);
+ $key = self::extractKeyCell($row['body']);
$groups[$key === null ? 'Other' : self::resolveVariableGroup($key)][] = $row[0];
}
@@ -888,10 +896,6 @@ private static function parseOverviewRows(string $overviewSrc): array
$key = trim(html_entity_decode(strip_tags($row[1]), ENT_QUOTES, 'UTF-8'));
$value = trim(html_entity_decode(strip_tags($row[2]), ENT_QUOTES, 'UTF-8'));
- if ($key === '' || stripos($key, 'PHP Logo') !== false) {
- continue;
- }
-
$rows[$key] = $value;
}
@@ -919,7 +923,7 @@ private static function redactSensitiveRows(string $tableBody): string
$redacted = preg_replace_callback(
self::TABLE_ROW_PATTERN,
static function (array $row): string {
- $key = self::extractKeyCell($row[2]);
+ $key = self::extractKeyCell($row['body']);
if ($key === null || self::isSensitiveVariableKey($key) === false) {
return $row[0];
@@ -928,10 +932,10 @@ static function (array $row): string {
$content = preg_replace(
'%
]*)class="[^"]*\bv\b[^"]*"([^>]*)>.*? %si',
'
redacted ',
- $row[2],
+ $row['body'],
);
- return '
' . ($content ?? $row[2]) . ' ';
+ return '
' . ($content ?? $row['body']) . ' ';
},
$tableBody,
);
@@ -947,13 +951,13 @@ static function (array $row): string {
private static function renderFactStatusPills(string $rowContent): string
{
$rendered = preg_replace_callback(
- '%
]*)>(.*?) %si',
+ '%
[^>]*)>(?.*?) %si',
static function (array $cell): string {
- if (preg_match('/\bclass="[^"]*\bv\b[^"]*"/i', $cell[1]) !== 1) {
+ if (preg_match('/\bclass="[^"]*\bv\b[^"]*"/i', $cell['attributes']) !== 1) {
return $cell[0];
}
- $kind = self::resolveStatusVariant(self::decodeCell($cell[2]));
+ $kind = self::resolveStatusVariant(self::decodeCell($cell['body']));
if ($kind === null) {
return $cell[0];
@@ -963,9 +967,9 @@ static function (array $cell): string {
return sprintf(
'
%s ',
- $cell[1],
+ $cell['attributes'],
$variant,
- trim($cell[2]),
+ trim($cell['body']),
);
},
$rowContent,
@@ -1139,7 +1143,7 @@ private static function splitTrimmed(string $value, string $separator): array
*/
private static function stripHeaderRow(string $tableBody): string
{
- return preg_replace(self::LEADING_HEADER_ROW_PATTERN, '', $tableBody, 1) ?? $tableBody;
+ return preg_replace(self::LEADING_HEADER_ROW_PATTERN, '', $tableBody) ?? $tableBody;
}
/**
@@ -1224,9 +1228,12 @@ private static function wrapModuleTables(string $modulesSrc, bool $redactSensiti
$wrapped = preg_replace_callback(
self::TABLE_PATTERN,
static function (array $table) use ($redactSensitiveVariables): string {
- $tableBody = $redactSensitiveVariables ? self::redactSensitiveRows($table[1]) : $table[1];
+ $tableBody = $redactSensitiveVariables
+ ? self::redactSensitiveRows($table['body'])
+ : $table['body'];
- return self::normalizeVariableTables($tableBody) ?? self::normalizeModuleTable($tableBody);
+ return self::normalizeVariableTables($tableBody)
+ ?? self::normalizeModuleTable($tableBody, '', false, null);
},
$modulesSrc,
);
diff --git a/src/Theme/ThemeResolver.php b/src/Theme/ThemeResolver.php
new file mode 100644
index 0000000..53c15fd
--- /dev/null
+++ b/src/Theme/ThemeResolver.php
@@ -0,0 +1,45 @@
+getCookieParams(), $request->getQueryParams());
+ * ```
+ */
+final class ThemeResolver
+{
+ /**
+ * Cookie written by the client-side theme toggle.
+ */
+ public const string COOKIE = 'yii-debug-toolbar-theme';
+
+ /**
+ * Query parameter carrying the link-time theme snapshot.
+ */
+ public const string QUERY_PARAM = 'yii_debug_theme';
+
+ /**
+ * Returns the effective theme (`'light'` or `'dark'`).
+ *
+ * @param array
$cookieParams Request cookies.
+ * @param array $queryParams Parsed query parameters.
+ */
+ public static function resolve(array $cookieParams, array $queryParams): string
+ {
+ $raw = $cookieParams[self::COOKIE] ?? $queryParams[self::QUERY_PARAM] ?? null;
+
+ return is_string($raw) && strtolower($raw) === 'dark' ? 'dark' : 'light';
+ }
+}
diff --git a/src/View/Grid/ActiveFilterBanner.php b/src/View/Grid/ActiveFilterBanner.php
new file mode 100644
index 0000000..46d4514
--- /dev/null
+++ b/src/View/Grid/ActiveFilterBanner.php
@@ -0,0 +1,100 @@
+ '404'],
+ * static fn(array $without): string => '/debug?cleared=1',
+ * );
+ * ```
+ */
+final class ActiveFilterBanner
+{
+ /**
+ * Returns the rendered banner HTML, or an empty string when no filters are active.
+ *
+ * @param array $activeFilters Attribute-to-value map of the currently applied filters.
+ * @param Closure(list): string $removeUrl Builds the link that drops the given attributes from the URL.
+ */
+ public static function render(array $activeFilters, Closure $removeUrl): string
+ {
+ if ($activeFilters === []) {
+ return '';
+ }
+
+ $count = count($activeFilters);
+
+ $pills = '';
+
+ foreach ($activeFilters as $attr => $val) {
+ $attribute = Span::tag()
+ ->class('yii-debug-active-filter-attr')
+ ->content($attr)
+ ->render();
+ $separator = Span::tag()
+ ->class('yii-debug-active-filter-sep')
+ ->content(':')
+ ->render();
+ $value = Span::tag()
+ ->class('yii-debug-active-filter-value')
+ ->content($val)
+ ->render();
+ $remove = Span::tag()
+ ->class('yii-debug-active-filter-x')
+ ->addAttribute('aria-hidden', 'true')
+ ->content('×')
+ ->render();
+
+ $pillContent = "{$attribute}{$separator}{$value}{$remove}";
+
+ $pills .= A::tag()
+ ->class('yii-debug-active-filter-pill')
+ ->addAttribute('title', 'Remove this filter')
+ ->href($removeUrl([$attr]))
+ ->html($pillContent)
+ ->render();
+ }
+
+ $label = Span::tag()
+ ->class('yii-debug-active-filters-label')
+ ->content($count . ' filter' . ($count === 1 ? '' : 's') . ' active')
+ ->render();
+
+ $list = Span::tag()->class('yii-debug-active-filters-list')->html($pills)->render();
+
+ $clearAll = A::tag()
+ ->class('yii-debug-active-filters-clear')
+ ->addAttribute('title', 'Clear all filters and show every row')
+ ->href($removeUrl(array_keys($activeFilters)))
+ ->content('Clear all')
+ ->render();
+
+ $content = "{$label}{$list}{$clearAll}";
+
+ return Div::tag()
+ ->class('yii-debug-active-filters')
+ ->addAttribute('role', 'group')
+ ->addAriaAttribute('label', 'Active filters')
+ ->html($content)
+ ->render();
+ }
+}
diff --git a/src/View/Grid/RowClass.php b/src/View/Grid/RowClass.php
new file mode 100644
index 0000000..ee30b7b
--- /dev/null
+++ b/src/View/Grid/RowClass.php
@@ -0,0 +1,39 @@
+` row classes used by the debug grids.
+ */
+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.
+ */
+ public static function for(string|null $level): array
+ {
+ $normalized = $level === 'error' ? 'danger' : $level;
+
+ if (!in_array($normalized, ['success', 'info', 'warning', 'danger'], true)) {
+ return [];
+ }
+
+ return ['class' => 'yii-debug-row-' . $normalized];
+ }
+}
diff --git a/src/View/History/HistoryCellRenderer.php b/src/View/History/HistoryCellRenderer.php
new file mode 100644
index 0000000..f2f0b62
--- /dev/null
+++ b/src/View/History/HistoryCellRenderer.php
@@ -0,0 +1,267 @@
+`).
+ * Link targets are pre-built URL strings supplied by the adapter, so the renderer stays framework-neutral.
+ */
+final class HistoryCellRenderer
+{
+ /**
+ * Builds the row-attributes map for one captured-request row. The `data-*` attributes feed the sidebar's history
+ * cursor.
+ *
+ * @param HistoryRow $row Typed history row.
+ * @param bool $isCritical Whether the row's status code counts as critical (adds the danger row class).
+ *
+ * @return array
+ */
+ public static function buildRowAttributes(HistoryRow $row, bool $isCritical): array
+ {
+ $base = $isCritical ? RowClass::for('danger') : [];
+
+ $base['data-yii-debug-tag'] = $row->tag;
+ $base['data-yii-debug-method'] = $row->method;
+ $base['data-yii-debug-url'] = $row->url;
+ $base['data-yii-debug-status'] = (string) $row->statusCode;
+ $base['data-yii-debug-time'] = $row->timeCompact;
+ $base['data-yii-debug-ajax'] = $row->ajax ? '1' : '';
+
+ return $base;
+ }
+
+ /**
+ * Renders the AJAX column cell (`'Yes'` / `'No'`).
+ */
+ public static function renderAjaxCell(HistoryRow $row): string
+ {
+ return $row->ajax ? 'Yes' : 'No';
+ }
+
+ /**
+ * Renders the duration column cell (`'X ms'` or `'(not set)'` muted placeholder when missing), with a micro-gauge
+ * rail scaled against the page maximum when one exists.
+ *
+ * @param HistoryRow $row Typed history row.
+ * @param float $maxProcessingTime Page maximum in seconds ({@see HistoryScale::$maxProcessingTime}).
+ */
+ public static function renderDurationCell(HistoryRow $row, float $maxProcessingTime): string
+ {
+ if ($row->processingTime === null) {
+ return Span::tag()
+ ->class('yii-debug-not-set')
+ ->content('(not set)')
+ ->render();
+ }
+
+ return Gauge::render(
+ number_format($row->processingTime * 1000) . ' ms',
+ $row->processingTime,
+ $maxProcessingTime,
+ );
+ }
+
+ /**
+ * Renders the memory column cell (`'X.XXX MB'` or `'(not set)'`), with a micro-gauge rail scaled against the page
+ * maximum when one exists.
+ *
+ * @param HistoryRow $row Typed history row.
+ * @param int $maxPeakMemory Page maximum in bytes ({@see HistoryScale::$maxPeakMemory}).
+ */
+ public static function renderMemoryCell(HistoryRow $row, int $maxPeakMemory): string
+ {
+ if ($row->peakMemory === null) {
+ return Span::tag()
+ ->class('yii-debug-not-set')
+ ->content('(not set)')
+ ->render();
+ }
+
+ return Gauge::render(
+ Format::bytesToMb($row->peakMemory, 3),
+ (float) $row->peakMemory,
+ (float) $maxPeakMemory,
+ );
+ }
+
+ /**
+ * Renders the method column cell as vocabulary-colored text, or an empty string when the method was not captured.
+ */
+ public static function renderMethodCell(HistoryRow $row): string
+ {
+ if ($row->method === '') {
+ return '';
+ }
+
+ return Span::tag()
+ ->class('yii-debug-method yii-debug-verb-' . Vocabulary::verb($row->method))
+ ->content($row->method)
+ ->render();
+ }
+
+ /**
+ * Renders the SQL-query column cell (count + warning chip + deep-link to the DB panel).
+ *
+ * @param HistoryRow $row Typed history row.
+ * @param string $url Pre-built link target to the DB panel view for this request.
+ * @param bool $isQueryCountCritical Whether the row's query count exceeds the critical threshold.
+ * @param int $criticalQueryThreshold Threshold surfaced in the warning tooltip when the count is critical.
+ */
+ public static function renderSqlCountCell(
+ HistoryRow $row,
+ string $url,
+ bool $isQueryCountCritical,
+ int $criticalQueryThreshold,
+ ): string {
+ $title = "Executed {$row->sqlCount} database queries.";
+
+ $warningParts = [];
+
+ if ($isQueryCountCritical) {
+ $warningParts[] = "Too many queries. Allowed count is {$criticalQueryThreshold}";
+ }
+
+ if ($row->excessiveCallersCount > 0) {
+ $callerLabel = $row->excessiveCallersCount === 1 ? 'caller is' : 'callers are';
+ $warningParts[] = "{$row->excessiveCallersCount} {$callerLabel} making too many calls.";
+ }
+
+ $warning = implode('
', $warningParts);
+
+ $content = (string) $row->sqlCount;
+
+ if ($warning !== '') {
+ $warningHtml = Span::tag()
+ ->title($warning)
+ ->content('⚠')
+ ->render();
+
+ $content = "{$content} {$warningHtml}";
+ }
+
+ return A::tag()
+ ->href($url)
+ ->title($title)
+ ->html($content)
+ ->render();
+ }
+
+ /**
+ * Renders the status-code badge cell; an uncaptured (`0`) code displays as a successful `200`.
+ */
+ public static function renderStatusCell(HistoryRow $row): string
+ {
+ $statusCode = $row->statusCode === 0 ? 200 : $row->statusCode;
+
+ return Span::tag()
+ ->class('yii-debug-badge yii-debug-status-' . Vocabulary::statusClass($statusCode))
+ ->content((string) $statusCode)
+ ->render();
+ }
+
+ /**
+ * Renders the summary header (``) with the request total and the
+ * status-bucket pills.
+ *
+ * @param HistorySummary $summary Typed summary aggregate.
+ * @param array $bucketUrls Bucket-label-to-URL map for the pill deep links (missing labels render
+ * without an `href`).
+ * @param string $pageSizeHtml Pre-rendered page-size selector markup supplied by the adapter.
+ */
+ public static function renderSummary(HistorySummary $summary, array $bucketUrls, string $pageSizeHtml): string
+ {
+ if ($summary->totalRequests === 0) {
+ return '';
+ }
+
+ $requestLabel = $summary->totalRequests === 1 ? 'captured request' : 'captured requests';
+
+ $children = [
+ Span::tag()->html(
+ Strong::tag()->content((string) $summary->totalRequests),
+ " {$requestLabel}",
+ ),
+ ];
+
+ foreach ($summary->statusBuckets as $bucket) {
+ $children[] = Span::tag()
+ ->class('yii-debug-grid-summary-sep')
+ ->content('·');
+ $children[] = A::tag()
+ ->class("yii-debug-grid-summary-stat-{$bucket->variant}")
+ ->href($bucketUrls[$bucket->label] ?? '')
+ ->title("Filter to {$bucket->label} responses (sample {$bucket->sampleCode})")
+ ->html(Strong::tag()->content((string) $bucket->count), " {$bucket->label}");
+ }
+
+ return Header::tag()
+ ->class('yii-debug-grid-summary')
+ ->html(...$children, ...[$pageSizeHtml])
+ ->render();
+ }
+
+ /**
+ * Renders the request-tag column cell as a link to the panel view.
+ *
+ * @param HistoryRow $row Typed history row.
+ * @param string $url Pre-built link target to the request's panel view.
+ */
+ public static function renderTagCell(HistoryRow $row, string $url): string
+ {
+ return A::tag()
+ ->class('yii-debug-tag-link')
+ ->href($url)
+ ->content($row->tag)
+ ->render();
+ }
+
+ /**
+ * Renders the time column cell — compact `HH:MM:SS` with a full `Y-m-d H:i:s` tooltip on hover.
+ */
+ public static function renderTimeCell(HistoryRow $row): string
+ {
+ if ($row->time === 0.0) {
+ return Span::tag()
+ ->class('yii-debug-not-set')
+ ->content('(not set)')
+ ->render();
+ }
+
+ $timestamp = (int) $row->time;
+
+ return Span::tag()
+ ->class('yii-debug-nowrap')
+ ->title(date('Y-m-d H:i:s', $timestamp))
+ ->content(date('H:i:s', $timestamp))
+ ->render();
+ }
+
+ /**
+ * Renders the URL column cell with a hover-truncate wrapper.
+ */
+ public static function renderUrlCell(HistoryRow $row): string
+ {
+ return Span::tag()
+ ->class('yii-debug-url-cell')
+ ->title($row->url)
+ ->content($row->url)
+ ->render();
+ }
+}
diff --git a/src/View/History/HistoryRow.php b/src/View/History/HistoryRow.php
new file mode 100644
index 0000000..057faa7
--- /dev/null
+++ b/src/View/History/HistoryRow.php
@@ -0,0 +1,95 @@
+tag,
+ method: $summary->method,
+ url: $summary->url,
+ statusCode: $summary->statusCode,
+ time: $summary->time,
+ timeCompact: $summary->time > 0 ? date('H:i:s', (int) $summary->time) : '',
+ processingTime: $summary->processingTime,
+ peakMemory: $summary->peakMemory,
+ ip: $summary->ip,
+ sqlCount: $summary->sqlCount,
+ mailCount: $summary->mailCount,
+ excessiveCallersCount: $summary->excessiveCallersCount,
+ ajax: $summary->ajax,
+ );
+ }
+}
diff --git a/src/View/History/HistoryScale.php b/src/View/History/HistoryScale.php
new file mode 100644
index 0000000..b68fa23
--- /dev/null
+++ b/src/View/History/HistoryScale.php
@@ -0,0 +1,53 @@
+ $models Rows as supplied by the data provider.
+ */
+ public static function fromModels(array $models): self
+ {
+ $maxProcessingTime = 0.0;
+ $maxPeakMemory = 0;
+
+ foreach ($models as $row) {
+ if ($row->processingTime !== null) {
+ $maxProcessingTime = max($maxProcessingTime, $row->processingTime);
+ }
+
+ if ($row->peakMemory !== null) {
+ $maxPeakMemory = max($maxPeakMemory, $row->peakMemory);
+ }
+ }
+
+ return new self(
+ maxProcessingTime: $maxProcessingTime,
+ maxPeakMemory: $maxPeakMemory,
+ );
+ }
+}
diff --git a/src/View/History/HistoryStatusBucket.php b/src/View/History/HistoryStatusBucket.php
new file mode 100644
index 0000000..d308b7f
--- /dev/null
+++ b/src/View/History/HistoryStatusBucket.php
@@ -0,0 +1,31 @@
+
+ */
+ public array $statusBuckets,
+ /**
+ * Unique status-code map (`code => code`) consumed by the grid's status filter dropdown; `null` when the
+ * manifest has no captured statuses (the dropdown collapses to a text input).
+ *
+ * @var array|null
+ */
+ public array|null $statusCodeFilter,
+ ) {}
+
+ /**
+ * Builds the typed summary from the request manifest.
+ *
+ * @param array $manifest Manifest entries; only the values are read.
+ */
+ public static function fromManifest(array $manifest): self
+ {
+ $totalRequests = count($manifest);
+
+ $buckets = ['2xx' => 0, '3xx' => 0, '4xx' => 0, '5xx' => 0];
+ $sample = [];
+ $codes = [];
+
+ foreach ($manifest as $entry) {
+ $statusCode = $entry->statusCode;
+
+ if ($statusCode > 0) {
+ $codes[$statusCode] = $statusCode;
+ }
+
+ if ($statusCode < 200 || $statusCode >= 600) {
+ continue;
+ }
+
+ $bucket = match (true) {
+ $statusCode >= 500 => '5xx',
+ $statusCode >= 400 => '4xx',
+ $statusCode >= 300 => '3xx',
+ default => '2xx',
+ };
+
+ $buckets[$bucket]++;
+ $sample[$bucket] ??= $statusCode;
+ }
+
+ $statusBuckets = [];
+
+ foreach ($buckets as $label => $count) {
+ if (!isset($sample[$label])) {
+ continue;
+ }
+
+ $statusBuckets[] = new HistoryStatusBucket(
+ label: $label,
+ count: $count,
+ sampleCode: $sample[$label],
+ variant: $label,
+ );
+ }
+
+ ksort($codes);
+
+ return new self(
+ totalRequests: $totalRequests,
+ statusBuckets: $statusBuckets,
+ statusCodeFilter: $codes === [] ? null : $codes,
+ );
+ }
+}
diff --git a/src/View/Sidebar/SidebarNavItem.php b/src/View/Sidebar/SidebarNavItem.php
new file mode 100644
index 0000000..30b28dd
--- /dev/null
+++ b/src/View/Sidebar/SidebarNavItem.php
@@ -0,0 +1,38 @@
+`.
+ */
+ public bool $isActive,
+ ) {}
+}
diff --git a/src/View/Sidebar/SidebarRenderer.php b/src/View/Sidebar/SidebarRenderer.php
new file mode 100644
index 0000000..6a2f382
--- /dev/null
+++ b/src/View/Sidebar/SidebarRenderer.php
@@ -0,0 +1,278 @@
+` with the snapshot card + panel nav).
+ *
+ * Usage example:
+ * ```php
+ * $html = \PHPForge\Debug\View\Sidebar\SidebarRenderer::render($view);
+ * ```
+ */
+ public static function render(SidebarView $view): string
+ {
+ $children = [];
+
+ if ($view->snapshot !== null) {
+ $children[] = self::renderSnapshotSection($view->snapshot);
+ }
+
+ $children[] = self::renderPanelNav($view->navItems);
+
+ return Aside::tag()
+ ->class('yii-debug-sidebar')
+ ->html(...$children)
+ ->render();
+ }
+
+ /**
+ * Renders the snapshot card body (method/url line + meta strip + navigator row).
+ */
+ private static function renderHistoryCard(SidebarSnapshot $snapshot): Div
+ {
+ $method = $snapshot->method !== '' ? "{$snapshot->method} " : '';
+
+ return Div::tag()
+ ->class('yii-debug-history-card')
+ ->title("{$method}{$snapshot->fullUrl}")
+ ->html(
+ Div::tag()
+ ->class('yii-debug-snapshot-line')
+ ->html(
+ Span::tag()
+ ->class('yii-debug-snapshot-method yii-debug-verb-' . Vocabulary::verb($snapshot->method))
+ ->addDataAttribute('snapshot-field', 'method')
+ ->content($snapshot->method),
+ Span::tag()
+ ->class('yii-debug-snapshot-url')
+ ->addDataAttribute('snapshot-field', 'url')
+ ->title($snapshot->fullUrl)
+ ->content($snapshot->path),
+ ),
+ self::renderMetaStrip($snapshot),
+ self::renderNavRow($snapshot),
+ );
+ }
+
+ /**
+ * Renders the snapshot card meta strip (status pill + time chip + AJAX tag).
+ */
+ private static function renderMetaStrip(SidebarSnapshot $snapshot): Div
+ {
+ $time = Span::tag()
+ ->class('yii-debug-snapshot-time')
+ ->addDataAttribute('snapshot-field', 'time')
+ ->content($snapshot->time);
+
+ if ($snapshot->time === '') {
+ $time = $time->addAttribute('hidden', true);
+ }
+
+ $ajax = Span::tag()
+ ->class('yii-debug-snapshot-tag')
+ ->addDataAttribute('snapshot-field', 'ajax')
+ ->content('AJAX');
+
+ if ($snapshot->isAjax === false) {
+ $ajax = $ajax->addAttribute('hidden', true);
+ }
+
+ return Div::tag()
+ ->class('yii-debug-snapshot-meta')
+ ->html(
+ Span::tag()
+ ->class('yii-debug-snapshot-status yii-debug-status-' . $snapshot->statusVariant)
+ ->addDataAttribute('snapshot-field', 'status')
+ ->content($snapshot->statusCode > 0 ? (string) $snapshot->statusCode : '–'),
+ $time,
+ $ajax,
+ );
+ }
+
+ /**
+ * Renders one navigator button (either an anchor link or a cursor `` depending on cursor mode).
+ */
+ private static function renderNavButton(
+ bool $isCursor,
+ string $cursorTarget,
+ bool $isDisabled,
+ string $url,
+ string $title,
+ string $ariaLabel,
+ string $icon,
+ ): A|Button {
+ $disabledClass = $isDisabled ? ' is-disabled' : '';
+
+ $class = self::ICON_BTN_CLASS;
+
+ $class = "{$class}{$disabledClass}";
+
+ if ($isCursor || $isDisabled) {
+ $button = Button::tag()
+ ->type('button')
+ ->class($class)
+ ->disabled($isDisabled)
+ ->title($title)
+ ->addAriaAttribute('label', $ariaLabel)
+ ->html($icon);
+
+ return $isCursor ? $button->addDataAttribute('yii-debug-cursor', $cursorTarget) : $button;
+ }
+
+ return A::tag()
+ ->class($class)
+ ->href($url)
+ ->title($title)
+ ->addAriaAttribute('label', $ariaLabel)
+ ->html($icon);
+ }
+
+ /**
+ * Renders the navigator row (Newest | Newer | Older | Oldest), branching between cursor-mode buttons and
+ * navigation-mode anchor links.
+ */
+ private static function renderNavRow(SidebarSnapshot $snapshot): Div
+ {
+ $iconNewest = Icon::render('chevrons-up');
+ $iconNewer = Icon::render('chevron-up');
+ $iconOlder = Icon::render('chevron-down');
+ $iconOldest = Icon::render('chevrons-down');
+
+ return Div::tag()
+ ->class('yii-debug-request-nav-row')
+ ->addAttribute('role', 'group')
+ ->html(
+ self::renderNavButton(
+ $snapshot->isCursor,
+ 'newest',
+ $snapshot->isNewest,
+ $snapshot->newestUrl,
+ 'Newest request',
+ 'Newest captured request',
+ $iconNewest,
+ ),
+ self::renderNavButton(
+ $snapshot->isCursor,
+ 'newer',
+ $snapshot->hasNewer === false,
+ $snapshot->newerUrl,
+ 'Newer request',
+ 'Newer captured request',
+ $iconNewer,
+ ),
+ self::renderNavButton(
+ $snapshot->isCursor,
+ 'older',
+ $snapshot->hasOlder === false,
+ $snapshot->olderUrl,
+ 'Older request',
+ 'Older captured request',
+ $iconOlder,
+ ),
+ self::renderNavButton(
+ $snapshot->isCursor,
+ 'oldest',
+ $snapshot->isOldest,
+ $snapshot->oldestUrl,
+ 'Oldest request',
+ 'Oldest captured request',
+ $iconOldest,
+ ),
+ );
+ }
+
+ /**
+ * Renders the bottom panel-nav (History + every non-config panel) as a {@see Menu} of {@see Item} entries.
+ *
+ * Each entry wraps its label in a `` and, when present, its icon SVG in a sibling ``. The active entry
+ * receives the `is-active` class and the `aria-current="page"` attribute; inactive entries are styled through
+ * `.yii-debug-nav-link:not(.is-active)`.
+ *
+ * @param list $items Navigation entries to render.
+ *
+ * @return string Rendered `` markup for the panel navigation.
+ */
+ private static function renderPanelNav(array $items): string
+ {
+ $menuItems = [];
+
+ foreach ($items as $item) {
+ $menuItem = Item::tag()
+ ->label($item->label)
+ ->labelTag(Inline::SPAN)
+ ->labelClass('yii-debug-nav-link-label')
+ ->link($item->url)
+ ->active($item->isActive)
+ ->linkAttributes(['title' => $item->tooltip]);
+
+ if ($item->iconSvg !== '') {
+ $menuItem = $menuItem
+ ->iconTag('span')
+ ->iconClass('yii-debug-nav-link-icon')
+ ->iconAttributes(['aria-hidden' => 'true'])
+ ->iconContent($item->iconSvg);
+ }
+
+ $menuItems[] = $menuItem;
+ }
+
+ return Menu::tag()
+ ->type('nav')
+ ->class('yii-debug-nav yii-debug-nav-iconed')
+ ->addAriaAttribute('label', 'Debug panels')
+ ->linkClass('yii-debug-nav-link')
+ ->linkActiveClass(['yii-debug-nav-link', 'is-active'])
+ ->linkAriaCurrent()
+ ->items(...$menuItems)
+ ->render();
+ }
+
+ /**
+ * Renders the top snapshot section (`` with header + history card).
+ */
+ private static function renderSnapshotSection(SidebarSnapshot $snapshot): Section
+ {
+ $section = Section::tag()
+ ->class('yii-debug-side-section yii-debug-request-nav')
+ ->addAriaAttribute('label', $snapshot->ariaLabel);
+
+ if ($snapshot->isCursor) {
+ $section = $section->addDataAttribute('yii-debug-history-cursor', true);
+
+ if ($snapshot->cursorInitTag !== '') {
+ $section = $section->addDataAttribute('yii-debug-cursor-init', $snapshot->cursorInitTag);
+ }
+ }
+
+ return $section->html(
+ Header::tag()->class('yii-debug-side-section-title')->content($snapshot->title),
+ self::renderHistoryCard($snapshot),
+ );
+ }
+}
diff --git a/src/View/Sidebar/SidebarSnapshot.php b/src/View/Sidebar/SidebarSnapshot.php
new file mode 100644
index 0000000..e8f65ff
--- /dev/null
+++ b/src/View/Sidebar/SidebarSnapshot.php
@@ -0,0 +1,96 @@
+` element.
+ */
+ public string $ariaLabel,
+ /**
+ * HTTP method ('GET', 'POST', ...). Empty when not captured.
+ */
+ public string $method,
+ /**
+ * Path-only URL display (scheme/host stripped). Empty when not captured.
+ */
+ public string $path,
+ /**
+ * Full URL captured in the request summary; used as the `title` hover on the URL chip.
+ */
+ public string $fullUrl,
+ /**
+ * Response status code; '0' when not captured.
+ */
+ public int $statusCode,
+ /**
+ * Status-pill CSS modifier ('success' / 'muted' / 'warning' / 'danger') derived from `$statusCode`.
+ */
+ public string $statusVariant,
+ /**
+ * Formatted request time ('HH:MM:SS'); empty when not captured.
+ */
+ public string $time,
+ /**
+ * Whether the captured request was an AJAX request; surfaces the 'AJAX' tag in the card meta strip.
+ */
+ public bool $isAjax,
+ /**
+ * `true` when the sidebar is rendered for the index page and the navigator buttons act as a grid cursor.
+ */
+ public bool $isCursor,
+ /**
+ * Optional tag the cursor JS should land on when the sidebar arrives from a panel view's History link
+ * (`?cursor=`). Empty string falls back to the newest captured request.
+ */
+ public string $cursorInitTag,
+ /**
+ * Newest request link target (top of list); empty string renders an empty `href`.
+ */
+ public string $newestUrl,
+ /**
+ * Oldest request link target (bottom of list); empty string renders an empty `href`.
+ */
+ public string $oldestUrl,
+ /**
+ * Newer request link target; empty string when the snapshot is already on the newest row.
+ */
+ public string $newerUrl,
+ /**
+ * Older request link target; empty string when the snapshot is already on the oldest row.
+ */
+ public string $olderUrl,
+ /**
+ * `true` when the snapshot is the newest captured request; disables the Newest button.
+ */
+ public bool $isNewest,
+ /**
+ * `true` when the snapshot is the oldest captured request; disables the Oldest button.
+ */
+ public bool $isOldest,
+ /**
+ * `true` when there is a newer request available; controls the Newer button.
+ */
+ public bool $hasNewer,
+ /**
+ * `true` when there is an older request available; controls the Older button.
+ */
+ public bool $hasOlder,
+ ) {}
+}
diff --git a/src/View/Sidebar/SidebarView.php b/src/View/Sidebar/SidebarView.php
new file mode 100644
index 0000000..67b3ec7
--- /dev/null
+++ b/src/View/Sidebar/SidebarView.php
@@ -0,0 +1,24 @@
+
+ */
+ public array $navItems,
+ ) {}
+}
diff --git a/tests/Helper/DumpTest.php b/tests/Helper/DumpTest.php
index 0e76fbf..2ccc29a 100644
--- a/tests/Helper/DumpTest.php
+++ b/tests/Helper/DumpTest.php
@@ -81,6 +81,30 @@ public function testAsStringRendersUnsupportedTypesAsPlaceholders(): void
'Objects must degrade to their type placeholder.',
);
}
+ public function testAsStringUsesTenAsTheDefaultDepth(): void
+ {
+ $value = 'leaf';
+
+ for ($level = 0; $level < 11; $level++) {
+ $value = [$value];
+ }
+
+ self::assertSame(
+ Dump::asString($value, 10),
+ Dump::asString($value),
+ 'The documented default must render exactly ten nested array levels.',
+ );
+ self::assertNotSame(
+ Dump::asString($value, 9),
+ Dump::asString($value),
+ 'The default must not collapse at the ninth level.',
+ );
+ self::assertNotSame(
+ Dump::asString($value, 11),
+ Dump::asString($value),
+ 'The default must not expose the eleventh nested array level.',
+ );
+ }
public function testExportOmitsSequentialIntegerKeysAndKeepsExplicitOnes(): void
{
diff --git a/tests/Helper/TextTest.php b/tests/Helper/TextTest.php
new file mode 100644
index 0000000..4dbde40
--- /dev/null
+++ b/tests/Helper/TextTest.php
@@ -0,0 +1,34 @@
+normalize(
+ self::rows(['app\\AppAsset' => ['css' => ['app.css']]]),
+ );
+
+ $bundle = $summary->bundles[0] ?? self::fail('Expected one bundle.');
+ $html = AssetCardRenderer::renderCard($bundle, $summary)->render();
+
+ self::assertStringContainsString(
+ '1 css<',
+ $html,
+ "CSS-only bundle must render the 'css' chip.",
+ );
+ self::assertStringContainsString(
+ 'class="yii-debug-asset-file-type yii-debug-asset-file-type-css"',
+ $html,
+ 'CSS file rows must be rendered through the typed file renderer.',
+ );
+ self::assertStringContainsString(
+ 'title="app.css">app.css<',
+ $html,
+ 'CSS file rows must retain the file label and tooltip.',
+ );
+ }
+
public function testRenderCardEmitsJsFilesListAndChipForJsOnlyBundle(): void
{
$summary = (new AssetBundleNormalizer())
@@ -115,7 +142,7 @@ public function testRenderCardEmitsShortNameAndNamespacePrefix(): void
'Header must render the bundle short name.',
);
self::assertStringContainsString(
- 'vendor\\package\\',
+ 'class="yii-debug-asset-card-fqcn">vendor\\package\\<',
$html,
'Header must render the namespace prefix.',
);
@@ -234,6 +261,11 @@ public function testRenderCardOmitsBodyWhenBundleHasNoFilesOrWiringOrDeps(): voi
$html,
'No body means no sections.',
);
+ self::assertStringNotContainsString(
+ 'yii-debug-asset-chip-',
+ $html,
+ 'Zero CSS, JS, and dependency counts must omit their header chips.',
+ );
}
public function testRenderCardWiringRendersBasePathRow(): void
diff --git a/tests/Panel/Config/ConfigCardRendererTest.php b/tests/Panel/Config/ConfigCardRendererTest.php
index 599fe2f..5167232 100644
--- a/tests/Panel/Config/ConfigCardRendererTest.php
+++ b/tests/Panel/Config/ConfigCardRendererTest.php
@@ -38,15 +38,20 @@ public function testRenderApplicationDetailsSectionShowsCharsetAndLanguageRows()
'Current language row must be labeled.'
);
self::assertStringContainsString(
- 'en-US',
+ 'en-US (English, United States)',
$html,
- 'Current language value must be rendered.'
+ 'Current language must include its language and region display names.'
);
self::assertStringContainsString(
'Source language',
$html,
'Source language row must be labeled.'
);
+ self::assertStringContainsString(
+ 'en (English)',
+ $html,
+ 'Source language must include its display language.',
+ );
}
public function testRenderApplicationDetailsSectionShowsEmDashWhenCharsetIsEmpty(): void
@@ -60,6 +65,11 @@ public function testRenderApplicationDetailsSectionShowsEmDashWhenCharsetIsEmpty
$html,
'Empty charset must render the em-dash placeholder.',
);
+ self::assertSame(
+ 3,
+ substr_count($html, '—'),
+ 'Empty charset, current language, and source language must each render a placeholder.',
+ );
}
public function testRenderInstalledExtensionsSectionListsEveryPackageWithVersionPrefix(): void
@@ -144,14 +154,18 @@ public function testRenderPhpExtensionsSectionEmitsOneOnAndThreeOffPills(): void
'Memcached label must be present.',
);
self::assertStringContainsString(
- 'is-on',
+ 'class="yii-debug-ext-pill is-on"> '
+ . 'Xdebug '
+ . 'on ',
$html,
- "On state must use the 'is-on' modifier.",
+ "Enabled Xdebug must pair the 'is-on' modifier with the 'on' label.",
);
self::assertStringContainsString(
- 'is-off',
+ 'class="yii-debug-ext-pill is-off"> '
+ . 'APCu '
+ . 'off ',
$html,
- "Off state must use the 'is-off' modifier.",
+ "Disabled APCu must pair the 'is-off' modifier with the 'off' label.",
);
}
@@ -220,6 +234,11 @@ public function testRenderReadoutGridShowsDebugOnChipWhenDebugIsTrue(): void
$html,
"Debug chip must read 'on' when debug is 'true'.",
);
+ self::assertStringContainsString(
+ 'debug',
+ $html,
+ 'Stringable readout metadata must be rendered as HTML instead of escaped text.',
+ );
self::assertStringNotContainsString(
'yii-debug-readout-chip-muted">debug',
$html,
diff --git a/tests/Panel/Dump/DumpCardRendererTest.php b/tests/Panel/Dump/DumpCardRendererTest.php
index d6d3cf1..e5d7c59 100644
--- a/tests/Panel/Dump/DumpCardRendererTest.php
+++ b/tests/Panel/Dump/DumpCardRendererTest.php
@@ -18,6 +18,18 @@
#[Group('dump')]
final class DumpCardRendererTest extends TestCase
{
+ public function testRenderMessageCellDecodesHtml5QuoteEntitiesBeforeTypeDetection(): void
+ {
+ self::assertStringContainsString(
+ 'data-type="string"',
+ DumpCardRenderer::renderMessageCell(
+ self::makeRow(message: '<?php 'hello''),
+ self::traceLine(),
+ 0,
+ ),
+ 'HTML5 apostrophe entities must decode to a quoted string payload.',
+ );
+ }
public function testRenderMessageCellEmitsIndexBadgeBasedOnIndex(): void
{
$html = DumpCardRenderer::renderMessageCell(
@@ -58,6 +70,66 @@ public function testRenderMessageCellEmitsTraceListWhenTraceHasFrames(): void
);
}
+ public function testRenderMessageCellFormatsMillisecondsAtTheUpperBoundary(): void
+ {
+ $html = DumpCardRenderer::renderMessageCell(
+ self::makeRow(time: 1_700_000_000.1239),
+ self::traceLine(),
+ 0,
+ );
+
+ self::assertStringContainsString(
+ date('H:i:s', 1_700_000_000) . '.123',
+ $html,
+ 'Millisecond conversion must use exactly one thousand units per second.',
+ );
+ }
+
+ public function testRenderMessageCellKeepsTimeAndTraceMetadataTogether(): void
+ {
+ $html = DumpCardRenderer::renderMessageCell(
+ self::makeRow(time: 1_700_000_000.5, trace: [['file' => '/app/User.php', 'line' => 42]]),
+ self::traceLine(),
+ 0,
+ );
+
+ self::assertStringContainsString('yii-debug-dump-time', $html, 'Time metadata must be retained.');
+ self::assertStringContainsString('yii-debug-dump-trace', $html, 'Trace metadata must be retained.');
+ }
+
+ public function testRenderMessageCellNormalizesUppercaseScalarIdentifiers(): void
+ {
+ self::assertStringContainsString(
+ 'data-type="bool"',
+ DumpCardRenderer::renderMessageCell(
+ self::makeRow(message: '<?php TRUE'),
+ self::traceLine(),
+ 0,
+ ),
+ 'Boolean identifier matching must remain case-insensitive.',
+ );
+ }
+
+ public function testRenderMessageCellOmitsNonPositiveTraceLineSuffix(): void
+ {
+ $html = DumpCardRenderer::renderMessageCell(
+ self::makeRow(trace: [['file' => '/app/User.php', 'line' => 0]]),
+ self::traceLine(),
+ 0,
+ );
+
+ self::assertStringContainsString(
+ 'class="yii-debug-dump-trace" title="/app/User.php">User.php ',
+ $html,
+ 'Line zero must not be exposed as a source location suffix.',
+ );
+ self::assertStringNotContainsString(
+ 'User.php:0',
+ $html,
+ 'Line zero must remain absent from the label and tooltip.',
+ );
+ }
+
public function testRenderMessageCellOmitsTimeWhenTimeIsZero(): void
{
self::assertStringNotContainsString(
@@ -172,6 +244,19 @@ public function testRenderMessageCellRendersTraceLabelWithBasenameAndLine(): voi
);
}
+ public function testRenderMessageCellRequiresIdentifierAtThePayloadStart(): void
+ {
+ self::assertStringNotContainsString(
+ 'yii-debug-dump-type',
+ DumpCardRenderer::renderMessageCell(
+ self::makeRow(message: '<?php +Widget'),
+ self::traceLine(),
+ 0,
+ ),
+ 'An identifier after an unsupported leading symbol must not be classified as an object.',
+ );
+ }
+
public function testRenderMessageCellSniffsArrayTypeFromOpeningBracket(): void
{
$html = DumpCardRenderer::renderMessageCell(
@@ -282,6 +367,19 @@ public function testRenderMessageCellSniffsStringTypeFromQuoteCharacter(): void
);
}
+ public function testRenderMessageCellTrimsWhitespaceBeforeTypeDetection(): void
+ {
+ self::assertStringContainsString(
+ 'data-type="number"',
+ DumpCardRenderer::renderMessageCell(
+ self::makeRow(message: ' 42'),
+ self::traceLine(),
+ 0,
+ ),
+ 'Leading whitespace without a PHP prefix must not hide a numeric payload.',
+ );
+ }
+
public function testRenderMessageCellWrapsPayloadInTheDumpCardContainer(): void
{
$html = DumpCardRenderer::renderMessageCell(
diff --git a/tests/Panel/Log/LogCellRendererTest.php b/tests/Panel/Log/LogCellRendererTest.php
index 5bf124d..8883f8d 100644
--- a/tests/Panel/Log/LogCellRendererTest.php
+++ b/tests/Panel/Log/LogCellRendererTest.php
@@ -241,6 +241,15 @@ public function testRenderMessageCellLeavesShortMessageUnclamped(): void
);
}
+ public function testRenderTimeCellFormatsMillisecondsAtTheUpperBoundary(): void
+ {
+ self::assertSame(
+ date('H:i:s', 1_700_000_000) . '.123',
+ LogCellRenderer::renderTimeCell(self::makeRow(time: 1_700_000_000_123.0)),
+ 'Millisecond conversion must use exactly one thousand units per second.',
+ );
+ }
+
public function testRenderTimeCellFormatsMillisecondTimestampAsHmsWithMillis(): void
{
$expected = date('H:i:s', 1_700_000_000) . '.789';
@@ -256,6 +265,15 @@ public function testRenderTimeCellFormatsMillisecondTimestampAsHmsWithMillis():
);
}
+ public function testRenderTimeCellTruncatesFractionalMillisecondsWithoutImplicitConversion(): void
+ {
+ self::assertSame(
+ date('H:i:s', 1_787_057_930) . '.402',
+ LogCellRenderer::renderTimeCell(self::makeRow(time: 1_787_057_930_402.636)),
+ 'Fractional milliseconds must be truncated explicitly before integer arithmetic.',
+ );
+ }
+
public function testRenderTimeSincePreviousCellEmitsAbsoluteDiffWithUnitsAndArrows(): void
{
$html = LogCellRenderer::renderTimeSincePreviousCell(
@@ -306,24 +324,9 @@ public function testRenderTimeSincePreviousCellIncludesHoursAndMinutesWhenDeltaE
);
self::assertStringContainsString(
- '2h',
- $html,
- 'Diff must include the hours component.',
- );
- self::assertStringContainsString(
- '5m',
- $html,
- 'Diff must include the minutes component.',
- );
- self::assertStringContainsString(
- '7s',
+ '2h' . "\u{00A0}" . '5m' . "\u{00A0}" . '7s' . "\u{00A0}" . '250ms',
$html,
- 'Diff must include the seconds component.',
- );
- self::assertStringContainsString(
- '250ms',
- $html,
- 'Diff must include the milliseconds component.',
+ 'Diff components must be integral and rendered in exact descending unit order.',
);
}
@@ -353,6 +356,26 @@ public function testRenderTimeSincePreviousCellRendersDisabledArrowsAtBoundaries
$html,
"Equal timestamps must render '0ms'.",
);
+ self::assertStringNotContainsString("0h\u{00A0}", $html, 'Zero hours must be omitted.');
+ self::assertStringNotContainsString("0m\u{00A0}", $html, 'Zero minutes must be omitted.');
+ self::assertStringNotContainsString("0s\u{00A0}", $html, 'Zero seconds must be omitted.');
+ }
+
+ public function testRenderTimeSincePreviousCellUsesSixtyMinutesPerHour(): void
+ {
+ $base = 1_700_000_000_000.0;
+
+ $belowHour = LogCellRenderer::renderTimeSincePreviousCell(
+ self::makeRow(time: $base + (59 * 60 + 30) * 1000, timeOfPrevious: $base),
+ );
+ $aboveHour = LogCellRenderer::renderTimeSincePreviousCell(
+ self::makeRow(time: $base + (60 * 60 + 30) * 1000, timeOfPrevious: $base),
+ );
+
+ self::assertStringNotContainsString('1h', $belowHour, 'Fifty-nine minutes must stay below one hour.');
+ self::assertStringContainsString('59m', $belowHour, 'The remaining minutes must be preserved below one hour.');
+ self::assertStringContainsString('1h', $aboveHour, 'Sixty minutes must roll over to one hour.');
+ self::assertStringNotContainsString('60m', $aboveHour, 'Rolled-over hours must leave zero minutes omitted.');
}
/**
diff --git a/tests/Panel/Mail/MailCardRendererTest.php b/tests/Panel/Mail/MailCardRendererTest.php
index 0da2a71..f090c40 100644
--- a/tests/Panel/Mail/MailCardRendererTest.php
+++ b/tests/Panel/Mail/MailCardRendererTest.php
@@ -7,6 +7,7 @@
use PHPForge\Debug\Panel\Mail\{MailCardRenderer, MailMessage};
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
+use Xepozz\InternalMocker\MockerState;
/**
* Unit tests for {@see MailCardRenderer} covering the typed mail card composition: avatar / headline / meta line,
@@ -16,6 +17,8 @@
#[Group('mail')]
final class MailCardRendererTest extends TestCase
{
+ private const int NOW = 1_700_000_000;
+
public function testRenderItemAvatarFallsBackToFixedHueWhenSenderIsEmpty(): void
{
$html = MailCardRenderer::renderItem(
@@ -35,6 +38,30 @@ public function testRenderItemAvatarFallsBackToFixedHueWhenSenderIsEmpty(): void
);
}
+ public function testRenderItemBodyPreviewUsesUnicodeCharacterBoundaries(): void
+ {
+ $exact = MailCardRenderer::renderItem(
+ self::makeMessage(body: str_repeat('é', 140)),
+ self::makeUrlBuilder(),
+ );
+ $long = MailCardRenderer::renderItem(
+ self::makeMessage(body: 'É' . str_repeat('é', 140)),
+ self::makeUrlBuilder(),
+ );
+
+ self::assertStringContainsString(
+ 'class="yii-debug-mail-preview">' . str_repeat('é', 140) . ' ',
+ $exact,
+ 'Exactly 140 Unicode characters must remain complete and omit the ellipsis.',
+ );
+ self::assertStringNotContainsString('…', $exact, 'The exact preview limit must not be treated as overflow.');
+ self::assertStringContainsString(
+ 'class="yii-debug-mail-preview">É' . str_repeat('é', 139) . '… ',
+ $long,
+ 'Long Unicode previews must start at the first character and truncate on a character boundary.',
+ );
+ }
+
public function testRenderItemEscapesBodyContent(): void
{
$html = MailCardRenderer::renderItem(
@@ -56,13 +83,15 @@ public function testRenderItemEscapesBodyContent(): void
public function testRenderItemFormatsRelativeTimeForDaysAgoDelta(): void
{
+ self::freezeTime();
+
$html = MailCardRenderer::renderItem(
- self::makeMessage(time: time() - (3 * 86400)),
+ self::makeMessage(time: self::NOW - (3 * 86400)),
self::makeUrlBuilder(),
);
self::assertStringContainsString(
- 'd ago',
+ '3 d ago',
$html,
"Days delta must read 'X d ago'.",
);
@@ -70,13 +99,15 @@ public function testRenderItemFormatsRelativeTimeForDaysAgoDelta(): void
public function testRenderItemFormatsRelativeTimeForHoursAgoDelta(): void
{
+ self::freezeTime();
+
$html = MailCardRenderer::renderItem(
- self::makeMessage(time: time() - 7200),
+ self::makeMessage(time: self::NOW - 7200),
self::makeUrlBuilder(),
);
self::assertStringContainsString(
- 'h ago',
+ '2 h ago',
$html,
"Hours delta must read 'X h ago'.",
);
@@ -84,8 +115,10 @@ public function testRenderItemFormatsRelativeTimeForHoursAgoDelta(): void
public function testRenderItemFormatsRelativeTimeForJustNowDelta(): void
{
+ self::freezeTime();
+
$html = MailCardRenderer::renderItem(
- self::makeMessage(time: time()),
+ self::makeMessage(time: self::NOW),
self::makeUrlBuilder(),
);
@@ -98,13 +131,15 @@ public function testRenderItemFormatsRelativeTimeForJustNowDelta(): void
public function testRenderItemFormatsRelativeTimeForMinutesAgoDelta(): void
{
+ self::freezeTime();
+
$html = MailCardRenderer::renderItem(
- self::makeMessage(time: time() - 600),
+ self::makeMessage(time: self::NOW - 600),
self::makeUrlBuilder(),
);
self::assertStringContainsString(
- 'min ago',
+ '10 min ago',
$html,
"Minutes delta must read 'X min ago'.",
);
@@ -227,6 +262,31 @@ public function testRenderItemRendersDownloadLinkWhenFileIsSet(): void
);
}
+ public function testRenderItemRendersEachRecipientGroupWhenItIsTheOnlyPopulatedList(): void
+ {
+ $cases = [
+ 'to' => [['only-to@example.com'], [], [], [], 'data-role="to"'],
+ 'cc' => [[], ['only-cc@example.com'], [], [], 'data-role="cc"'],
+ 'bcc' => [[], [], ['only-bcc@example.com'], [], 'data-role="bcc"'],
+ 'reply' => [[], [], [], ['only-reply@example.com'], 'data-role="reply"'],
+ ];
+
+ foreach ($cases as $name => [$to, $cc, $bcc, $replyTo, $role]) {
+ $html = MailCardRenderer::renderItem(
+ self::makeMessage(to: $to, cc: $cc, bcc: $bcc, replyTo: $replyTo),
+ self::makeUrlBuilder(),
+ );
+
+ self::assertStringContainsString('yii-debug-mail-recipients', $html, "{$name} alone must render recipients.");
+ self::assertStringContainsString($role, $html, "{$name} alone must render its role label.");
+ self::assertSame(
+ 1,
+ substr_count($html, 'class="yii-debug-mail-recipient-pill"'),
+ "{$name} must render one pill.",
+ );
+ }
+ }
+
public function testRenderItemRendersEmptyBodyPlaceholderWhenBodyIsEmpty(): void
{
$html = MailCardRenderer::renderItem(
@@ -289,7 +349,7 @@ public function testRenderItemRendersRecipientGroupsWithLabelsAndPills(): void
$html = MailCardRenderer::renderItem(
self::makeMessage(
to: ['a@example.com', 'b@example.com'],
- cc: ['cc@example.com'],
+ cc: ['carbon@example.com'],
bcc: ['bcc@example.com'],
replyTo: ['reply@example.com'],
),
@@ -327,10 +387,15 @@ public function testRenderItemRendersRecipientGroupsWithLabelsAndPills(): void
'TO pill must include the address.',
);
self::assertStringContainsString(
- 'cc@example.com',
+ 'title="carbon@example.com">carbon@example.com ',
$html,
'CC pill must include the address.',
);
+ self::assertSame(
+ 5,
+ substr_count($html, 'class="yii-debug-mail-recipient-pill"'),
+ 'Every declared recipient must be wrapped in its own pill.',
+ );
}
public function testRenderItemRendersStatusFailWhenIsSuccessfulIsFalse(): void
@@ -350,6 +415,11 @@ public function testRenderItemRendersStatusFailWhenIsSuccessfulIsFalse(): void
$html,
"Status label must read 'Failed'.",
);
+ self::assertStringContainsString(
+ 'title="Mailer reported failure"',
+ $html,
+ 'Failed status must retain its failure tooltip.',
+ );
}
public function testRenderItemRendersStatusOkWhenIsSuccessfulIsTrue(): void
@@ -369,6 +439,11 @@ public function testRenderItemRendersStatusOkWhenIsSuccessfulIsTrue(): void
$html,
"Status label must read 'Sent'.",
);
+ self::assertStringContainsString(
+ 'title="Mailer reported success"',
+ $html,
+ 'Successful status must retain its success tooltip.',
+ );
}
public function testRenderItemRendersTechDetailsWhenHeadersOrCharsetSet(): void
@@ -383,6 +458,11 @@ public function testRenderItemRendersTechDetailsWhenHeadersOrCharsetSet(): void
$html,
'Tech details wrapper must be present.',
);
+ self::assertStringContainsString(
+ 'class="yii-debug-mail-tech-icon"',
+ $html,
+ 'Technical details must retain the code icon.',
+ );
self::assertStringContainsString(
'Raw headers',
$html,
@@ -400,6 +480,23 @@ public function testRenderItemRendersTechDetailsWhenHeadersOrCharsetSet(): void
);
}
+ public function testRenderItemRendersTechDetailsWhenOnlyOneTechnicalFieldIsSet(): void
+ {
+ $headersOnly = MailCardRenderer::renderItem(
+ self::makeMessage(headers: 'X-Only: header', charset: ''),
+ self::makeUrlBuilder(),
+ );
+ $charsetOnly = MailCardRenderer::renderItem(
+ self::makeMessage(headers: '', charset: 'UTF-16'),
+ self::makeUrlBuilder(),
+ );
+
+ self::assertStringContainsString('yii-debug-mail-tech', $headersOnly, 'Headers alone must render details.');
+ self::assertStringContainsString('X-Only: header', $headersOnly, 'Header-only details must retain the value.');
+ self::assertStringContainsString('yii-debug-mail-tech', $charsetOnly, 'Charset alone must render details.');
+ self::assertStringContainsString('UTF-16', $charsetOnly, 'Charset-only details must retain the value.');
+ }
+
public function testRenderItemRendersTimeWhenSet(): void
{
$html = MailCardRenderer::renderItem(
@@ -419,6 +516,21 @@ public function testRenderItemRendersTimeWhenSet(): void
);
}
+ public function testRenderItemRendersUnicodeAndEmptyLocalPartInitials(): void
+ {
+ $unicode = MailCardRenderer::renderItem(
+ self::makeMessage(from: 'élise@example.com'),
+ self::makeUrlBuilder(),
+ );
+ $emptyLocal = MailCardRenderer::renderItem(
+ self::makeMessage(from: '@example.com'),
+ self::makeUrlBuilder(),
+ );
+
+ self::assertStringContainsString('>É<', $unicode, 'Unicode initials must be sliced and uppercased safely.');
+ self::assertStringContainsString('>@<', $emptyLocal, 'An empty local part must fall back to the full address.');
+ }
+
public function testRenderItemRendersUppercasedFirstLetterOfLocalPartAsInitial(): void
{
$html = MailCardRenderer::renderItem(
@@ -452,6 +564,42 @@ public function testRenderItemSkipsRecipientGroupsThatAreEmpty(): void
);
}
+ public function testRenderItemUsesExactRelativeTimeBoundariesAndUnits(): void
+ {
+ self::freezeTime();
+
+ $cases = [
+ ['1 min ago', 60, 'the minute boundary'],
+ ['1 h ago', 3600, 'the hour boundary'],
+ ['1 d ago', 86400, 'the day boundary'],
+ ['1 min ago', 118, 'a non-divisible minute delta'],
+ ['1 h ago', 7198, 'a non-divisible hour delta'],
+ ['1 d ago', 172798, 'a non-divisible day delta'],
+ ];
+
+ foreach ($cases as [$expected, $diff, $description]) {
+ $html = MailCardRenderer::renderItem(
+ self::makeMessage(time: self::NOW - $diff),
+ self::makeUrlBuilder(),
+ );
+
+ self::assertStringContainsString(
+ ">{$expected}<",
+ $html,
+ "Relative time must use the canonical unit for {$description}.",
+ );
+ }
+
+ $absolute = date('M j, Y · H:i:s', self::NOW - 2_592_000);
+ $html = MailCardRenderer::renderItem(
+ self::makeMessage(time: self::NOW - 2_592_000),
+ self::makeUrlBuilder(),
+ );
+
+ self::assertStringContainsString(">{$absolute}<", $html, 'Thirty days must switch to the absolute label.');
+ self::assertStringNotContainsString('30 d ago', $html, 'The thirty-day boundary must not stay relative.');
+ }
+
public function testRenderItemWrapsContentInArticleWithMailCardClass(): void
{
$html = MailCardRenderer::renderItem(
@@ -483,6 +631,17 @@ private static function extractHue(string $html): int
self::fail('No avatar hue found in rendered HTML.');
}
+ private static function freezeTime(): void
+ {
+ MockerState::addCondition(
+ 'PHPForge\\Debug\\Panel\\Mail',
+ 'time',
+ [],
+ self::NOW,
+ true,
+ );
+ }
+
/**
* @param list $to
* @param list $cc
diff --git a/tests/Panel/Queue/JobPayloadInspectorTest.php b/tests/Panel/Queue/JobPayloadInspectorTest.php
index da21423..6ba9675 100644
--- a/tests/Panel/Queue/JobPayloadInspectorTest.php
+++ b/tests/Panel/Queue/JobPayloadInspectorTest.php
@@ -113,6 +113,20 @@ public function testExtractCollapsesNestedObjectBeyondDepthLimit(): void
);
}
+ public function testExtractContinuesAfterAnUnreadableProperty(): void
+ {
+ $job = new class {
+ public int $uninitialized; // @phpstan-ignore property.uninitialized
+ public string $readable = 'after';
+ };
+
+ self::assertSame(
+ ['uninitialized' => '(unreadable)', 'readable' => 'after'],
+ JobPayloadInspector::extract($job),
+ 'An unreadable property must not prevent later public properties from being captured.',
+ );
+ }
+
public function testExtractExpandsNestedObjectWithClassMarker(): void
{
$inner = new class {
@@ -262,4 +276,86 @@ public function __construct(public mixed $stream) {}
fclose($handle);
}
+
+ public function testExtractUsesTheExactDepthBoundaryForArraysAndObjects(): void
+ {
+ $visibleArray = ['one' => ['two' => ['three' => ['four' => 'leaf']]]];
+ $truncatedArray = ['one' => ['two' => ['three' => ['four' => ['five' => ['six' => 'leaf']]]]]];
+
+ $visibleObject = new class {
+ public mixed $child = null;
+ };
+ $cursor = $visibleObject;
+
+ for ($level = 0; $level < 4; $level++) {
+ $next = new class {
+ public mixed $child = null;
+ };
+ $cursor->child = $next;
+ $cursor = $next;
+ }
+
+ $cursor->child = 'leaf';
+
+ $truncatedObject = new class {
+ public mixed $child = null;
+ };
+ $cursor = $truncatedObject;
+
+ for ($level = 0; $level < 5; $level++) {
+ $next = new class {
+ public mixed $child = null;
+ };
+ $cursor->child = $next;
+ $cursor = $next;
+ }
+
+ $job = new class ($visibleArray, $truncatedArray, $visibleObject, $truncatedObject) {
+ /**
+ * @param array $visibleArray
+ * @param array $truncatedArray
+ */
+ public function __construct(
+ public array $visibleArray,
+ public array $truncatedArray,
+ public object $visibleObject,
+ public object $truncatedObject,
+ ) {}
+ };
+
+ $fields = JobPayloadInspector::extract($job);
+
+ self::assertSame($visibleArray, $fields['visibleArray'] ?? null, 'Four nested arrays must remain visible.');
+ self::assertSame(
+ ['__truncated' => true],
+ self::valueAtPath($fields, ['truncatedArray', 'one', 'two', 'three', 'four', 'five']),
+ 'The sixth array depth must collapse at the exact boundary with a true marker.',
+ );
+ self::assertSame(
+ 'leaf',
+ self::valueAtPath($fields, ['visibleObject', 'child', 'child', 'child', 'child', 'child']),
+ 'Five nested object properties must remain visible before truncation.',
+ );
+ $truncated = self::valueAtPath(
+ $fields,
+ ['truncatedObject', 'child', 'child', 'child', 'child', 'child'],
+ );
+ self::assertIsArray($truncated, 'The sixth nested object must produce a structured marker.');
+ self::assertTrue($truncated['__truncated'] ?? false, 'The object depth marker must be true.');
+ self::assertArrayHasKey('__class', $truncated, 'A truncated object must retain its class name.');
+ }
+
+ /**
+ * @param list $path
+ */
+ private static function valueAtPath(mixed $value, array $path): mixed
+ {
+ foreach ($path as $key) {
+ self::assertIsArray($value, "Path segment '{$key}' must be traversable.");
+
+ $value = $value[$key] ?? null;
+ }
+
+ return $value;
+ }
}
diff --git a/tests/Panel/Queue/JobRecordTest.php b/tests/Panel/Queue/JobRecordTest.php
index 53147cb..ab012c8 100644
--- a/tests/Panel/Queue/JobRecordTest.php
+++ b/tests/Panel/Queue/JobRecordTest.php
@@ -17,6 +17,17 @@
#[Group('queue')]
final class JobRecordTest extends TestCase
{
+ public function testFromArrayRequiresAndSerializesTheEventType(): void
+ {
+ $payload = JobRecord::fromCapture(['eventType' => 'exec'])->jsonSerialize();
+ $record = JobRecord::fromArray($payload, '$.queue');
+ $serialized = $record->jsonSerialize();
+
+ self::assertSame('exec', $record->eventType, 'Hydration must read the required event type.');
+ self::assertArrayHasKey('eventType', $serialized, 'Serialization must retain the event type key.');
+ self::assertSame('exec', $serialized['eventType'] ?? null, 'Serialized event type must retain its value.');
+ }
+
public function testFromCaptureAcceptsEachKnownEventType(): void
{
self::assertSame(
@@ -341,4 +352,18 @@ public function testThrowHydrationExceptionForAnUnknownEventType(): void
'$.panels.queue.entries[0]',
);
}
+
+ public function testThrowHydrationExceptionWhenEventTypeIsMissing(): void
+ {
+ $payload = JobRecord::fromCapture(['eventType' => 'exec'])->jsonSerialize();
+
+ unset($payload['eventType']);
+
+ $this->expectException(HydrationException::class);
+ $this->expectExceptionMessage(
+ "Invalid debug snapshot value at '$.queue.eventType': expected a required field.",
+ );
+
+ JobRecord::fromArray($payload, '$.queue');
+ }
}
diff --git a/tests/Panel/Queue/QueueCardRendererTest.php b/tests/Panel/Queue/QueueCardRendererTest.php
index b710e94..138b244 100644
--- a/tests/Panel/Queue/QueueCardRendererTest.php
+++ b/tests/Panel/Queue/QueueCardRendererTest.php
@@ -66,6 +66,19 @@ public function testRenderAsyncHintReturnsNullWhenAllRecordsAreSync(): void
);
}
+ public function testRenderItemDriverPillUsesTheDriverClassOrFallbackTooltip(): void
+ {
+ $known = QueueCardRenderer::renderItem(
+ self::makeRecord(driverName: 'Redis', driverClass: 'yii\\queue\\redis\\Queue'),
+ )->render();
+ $unknown = QueueCardRenderer::renderItem(
+ self::makeRecord(driverName: 'Custom', driverClass: ''),
+ )->render();
+
+ self::assertStringContainsString('title="yii\\queue\\redis\\Queue">Redis<', $known, 'Known driver class must be the tooltip.');
+ self::assertStringContainsString('title="Unknown driver">Custom<', $unknown, 'Missing driver class must use the fallback tooltip.');
+ }
+
public function testRenderItemEmitsCardWithClassAndStatusPill(): void
{
$record = self::makeRecord(jobClass: 'app\\jobs\\HelloJob', eventType: 'push');
@@ -218,9 +231,9 @@ public function testRenderItemRendersCollapsibleBlockForNestedObjects(): void
'Object short class name must be visible.',
);
self::assertStringContainsString(
- 'app\\models',
+ 'class="yii-debug-queue-tree-class" title="app\\models\\Inner">app\\models\\Inner',
$html,
- 'Object namespace must be rendered.'
+ 'Object summary must retain the complete namespace and class name.'
);
self::assertStringContainsString(
'>42<',
@@ -474,6 +487,8 @@ public function testRenderItemRendersTruncatedMarkerInCollapsibleBlocks(): void
$html,
"Truncated marker must render the literal 'truncated' label.",
);
+ self::assertStringContainsString('>items<', $html, 'Truncated summary must retain its field key.');
+ self::assertStringContainsString('>array<', $html, 'Truncated associative array must retain its type.');
}
public function testRenderItemRendersTypeLabelsForEachScalarKind(): void
@@ -505,6 +520,16 @@ public function testRenderItemRendersTypeLabelsForEachScalarKind(): void
$html,
'Float type label must be present.',
);
+ self::assertMatchesRegularExpression(
+ '/>count<.*?>int<.*?>10ratio<.*?>float<.*?>1\.5bool<',
$html,
@@ -567,6 +592,27 @@ public function testRenderItemTruncatesLongStringValuesAndKeepsFullValueInTitle(
);
}
+ public function testRenderItemTruncatesStringsAtUnicodeCharacterBoundaries(): void
+ {
+ $exactValue = str_repeat('é', 80);
+ $longValue = 'É' . str_repeat('é', 80);
+
+ $exact = QueueCardRenderer::renderItem(self::makeRecord(payloadFields: ['data' => $exactValue]))->render();
+ $long = QueueCardRenderer::renderItem(self::makeRecord(payloadFields: ['data' => $longValue]))->render();
+
+ self::assertStringContainsString('>"' . $exactValue . '"<', $exact, 'Exactly 80 characters must not truncate.');
+ self::assertStringNotContainsString('…', $exact, 'The exact string limit must not add an ellipsis.');
+ self::assertStringContainsString('>"É' . str_repeat('é', 79) . '…"<', $long, 'Long Unicode strings must preserve the first 80 characters.');
+ }
+
+ public function testRenderItemUsesOneUnicodeCharacterForTheAvatarInitial(): void
+ {
+ $html = QueueCardRenderer::renderItem(self::makeRecord(jobClass: 'app\\jobs\\éclairJob'))->render();
+
+ self::assertStringContainsString('aria-hidden="true">É', $html, 'Avatar initial must be one complete Unicode character.');
+ self::assertStringNotContainsString('>Éc<', $html, 'Avatar initial must not include the second character.');
+ }
+
/**
* Extracts the queue avatar hue value from rendered HTML for hue-stability assertions.
*/
diff --git a/tests/Panel/Queue/QueueGridRendererTest.php b/tests/Panel/Queue/QueueGridRendererTest.php
index bf09fc3..a97095d 100644
--- a/tests/Panel/Queue/QueueGridRendererTest.php
+++ b/tests/Panel/Queue/QueueGridRendererTest.php
@@ -64,6 +64,11 @@ public function testRenderDriverCellAddsAsyncModifier(): void
$html,
'Driver label must appear in the cell.',
);
+ self::assertStringContainsString(
+ 'title="yii\\queue\\sync\\Queue"',
+ $html,
+ 'Known driver class must be retained in the tooltip.',
+ );
}
public function testRenderDriverCellAddsSyncModifierWhenInProcess(): void
@@ -84,6 +89,15 @@ public function testRenderDriverCellReturnsEmptyWhenDriverNameIsMissing(): void
);
}
+ public function testRenderDriverCellUsesFallbackTooltipWhenDriverClassIsMissing(): void
+ {
+ self::assertStringContainsString(
+ 'title="Unknown driver"',
+ QueueGridRenderer::renderDriverCell(self::makeRecord(driverName: 'Custom', driverClass: '')),
+ 'Missing driver class must use the explicit fallback tooltip.',
+ );
+ }
+
public function testRenderDurationCellFormatsMilliseconds(): void
{
self::assertSame(
@@ -91,6 +105,11 @@ public function testRenderDurationCellFormatsMilliseconds(): void
QueueGridRenderer::renderDurationCell(self::makeRecord(duration: 0.0123)),
"Seconds must be formatted as 'XX.X ms'.",
);
+ self::assertSame(
+ '1,000.0 ms',
+ QueueGridRenderer::renderDurationCell(self::makeRecord(duration: 1.0)),
+ 'One second must convert using exactly one thousand milliseconds.',
+ );
}
public function testRenderDurationCellReturnsDashWhenDurationIsNull(): void
@@ -142,7 +161,12 @@ public function testRenderJobCellSplitsFqcnAndWiresHref(): void
'Short class name must render in bold inside the link.',
);
self::assertStringContainsString(
- 'app\\jobs',
+ 'title="app\\jobs\\HelloJob"',
+ $html,
+ 'Job link tooltip must retain the full class name.',
+ );
+ self::assertStringContainsString(
+ 'class="yii-debug-queue-grid-job-namespace">app\\jobs\\',
$html,
'Namespace prefix must appear under the link.',
);
@@ -195,10 +219,19 @@ public function testRenderTimeCellFormatsMicrotimeAsHmsWithMilliseconds(): void
self::makeRecord(time: 1_704_112_496.789),
);
- self::assertMatchesRegularExpression(
- '/^\d{2}:\d{2}:\d{2}\.\d{3}$/',
+ self::assertSame(
+ date('H:i:s', 1_704_112_496) . '.789',
$html,
- "Time cell must follow 'HH:MM:SS.mmm'.",
+ "Time cell must preserve the exact 'HH:MM:SS.mmm' value.",
+ );
+ }
+
+ public function testRenderTimeCellTruncatesSubMillisecondPrecision(): void
+ {
+ self::assertSame(
+ date('H:i:s', 1_704_112_496) . '.123',
+ QueueGridRenderer::renderTimeCell(self::makeRecord(time: 1_704_112_496.1239)),
+ 'Sub-millisecond precision must truncate after three digits.',
);
}
diff --git a/tests/Panel/Request/RequestDataNormalizerTest.php b/tests/Panel/Request/RequestDataNormalizerTest.php
index 1344732..c875a23 100644
--- a/tests/Panel/Request/RequestDataNormalizerTest.php
+++ b/tests/Panel/Request/RequestDataNormalizerTest.php
@@ -74,22 +74,21 @@ public function testFromPanelDataDropsServerTabWhenServerKeyMissing(): void
public function testFromPanelDataDropsSessionTabWhenSessionOrFlashesMissing(): void
{
- $view = RequestDataNormalizer::fromPanelData(
- ['SERVER' => []],
- null,
- );
+ foreach ([[], ['SESSION' => []], ['flashes' => []]] as $data) {
+ $view = RequestDataNormalizer::fromPanelData($data, null);
- $labels = [];
+ $labels = [];
- foreach ($view->tabs as $tab) {
- $labels[] = $tab->label;
- }
+ foreach ($view->tabs as $tab) {
+ $labels[] = $tab->label;
+ }
- self::assertNotContains(
- 'Session',
- $labels,
- 'Without SESSION + flashes the Session tab must not surface.',
- );
+ self::assertNotContains(
+ 'Session',
+ $labels,
+ 'Without both SESSION and flashes the Session tab must not surface.',
+ );
+ }
}
public function testFromPanelDataExposesEveryTabWhenSessionAndServerArePresent(): void
@@ -138,6 +137,11 @@ public function testFromPanelDataFallsBackToEmptyViewWhenDataIsEmpty(): void
$view->hero->flags,
'Non-array data must yield zero flags.',
);
+ self::assertSame(
+ '',
+ $view->hero->time,
+ 'Missing capture time must not render the Unix epoch.',
+ );
self::assertCount(
2,
$view->tabs,
@@ -211,6 +215,51 @@ public function testFromPanelDataPrefersPanelStatusCodeOverSummary(): void
);
}
+ public function testFromPanelDataPreservesHeaderServerAndSessionSectionMetadata(): void
+ {
+ $view = RequestDataNormalizer::fromPanelData(
+ [
+ 'requestHeaders' => ['Accept' => 'text/html'],
+ 'responseHeaders' => ['Content-Type' => 'text/html'],
+ 'SESSION' => ['user' => 1],
+ 'flashes' => ['notice' => 'Saved'],
+ 'SERVER' => ['HTTP_HOST' => 'localhost'],
+ ],
+ null,
+ );
+
+ self::assertSame(
+ [
+ [
+ 'Headers',
+ [
+ ['Request Headers', ['Accept' => 'text/html'], true],
+ ['Response Headers', ['Content-Type' => 'text/html'], true],
+ ],
+ ],
+ [
+ 'Session',
+ [
+ ['Session', ['user' => 1], true],
+ ['Flashes', ['notice' => 'Saved'], false],
+ ],
+ ],
+ ['Server', [['Server', ['HTTP_HOST' => 'localhost'], true]]],
+ ],
+ array_map(
+ static fn($tab): array => [
+ $tab->label,
+ array_map(
+ static fn($section): array => [$section->caption, $section->entries, $section->filterable],
+ $tab->sections,
+ ),
+ ],
+ array_slice($view->tabs, 1),
+ ),
+ 'Header, Session, and Server tabs must retain every section and its filtering metadata.',
+ );
+ }
+
public function testFromPanelDataRoutingSectionAlwaysHasThreeEntries(): void
{
$view = RequestDataNormalizer::fromPanelData(
@@ -289,6 +338,25 @@ public function testFromPanelDataTreatsNonBoolFlagAsInactive(): void
);
}
+ public function testFromPanelDataUsesExactMillisecondConversionAndOmitsZeroTimestamp(): void
+ {
+ $view = RequestDataNormalizer::fromPanelData(
+ [],
+ self::summary(['time' => 0.0, 'processingTime' => 1.0]),
+ );
+
+ self::assertSame(
+ '',
+ $view->hero->time,
+ 'A zero capture timestamp must not render the Unix epoch.',
+ );
+ self::assertSame(
+ '1000.0 ms',
+ $view->hero->durationMs,
+ 'One second must convert to exactly one thousand milliseconds.',
+ );
+ }
+
/**
* @param array $overrides
*/
diff --git a/tests/Panel/Request/RequestSectionRendererTest.php b/tests/Panel/Request/RequestSectionRendererTest.php
index 2042802..31d7bcc 100644
--- a/tests/Panel/Request/RequestSectionRendererTest.php
+++ b/tests/Panel/Request/RequestSectionRendererTest.php
@@ -116,10 +116,25 @@ public function testRenderSectionEmitsFilterInputWhenFilterableAndNonEmpty(): vo
'Filterable section must expose a search input.',
);
self::assertStringContainsString(
- 'data-yii-debug-filter-target',
+ 'data-yii-debug-filter="true"',
+ $html,
+ 'Filterable section input must carry the enabled filtering marker.',
+ );
+ self::assertStringContainsString(
+ 'data-yii-debug-filter-target="true"',
$html,
'Filterable table must be the JS filter target.',
);
+ self::assertStringContainsString(
+ '' . "\n" . 'Server' . "\n" . ' ',
+ $html,
+ 'Section header must render its caption.',
+ );
+ self::assertStringContainsString(
+ "style='table-layout: fixed;'",
+ $html,
+ 'Section table must keep its fixed layout.',
+ );
}
public function testRenderSectionOmitsFilterInputWhenSectionIsEmpty(): void
@@ -142,7 +157,7 @@ public function testRenderSectionOmitsFilterInputWhenSectionIsEmpty(): void
public function testRenderSectionPicksHtmlSpecialCharsEscapingForRowValues(): void
{
- $section = new RequestSection(caption: 'Headers', entries: ['X-Custom' => '']);
+ $section = new RequestSection(caption: 'Headers', entries: ['X-Custom' => "'quoted' "]);
$html = RequestSectionRenderer::renderSection($section);
@@ -156,6 +171,11 @@ public function testRenderSectionPicksHtmlSpecialCharsEscapingForRowValues(): vo
$html,
'Tag characters must be escaped.',
);
+ self::assertStringContainsString(
+ ''',
+ $html,
+ 'Single quotes must be escaped by ENT_QUOTES.',
+ );
}
public function testRenderSectionRendersOneRowPerEntry(): void
diff --git a/tests/Panel/User/UserDataNormalizerTest.php b/tests/Panel/User/UserDataNormalizerTest.php
index 36b690d..57aaf70 100644
--- a/tests/Panel/User/UserDataNormalizerTest.php
+++ b/tests/Panel/User/UserDataNormalizerTest.php
@@ -7,6 +7,7 @@
use PHPForge\Debug\Panel\User\{UserAttribute, UserDataNormalizer};
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
+use Xepozz\InternalMocker\MockerState;
use function array_map;
@@ -19,6 +20,8 @@
#[Group('user')]
final class UserDataNormalizerTest extends TestCase
{
+ private const int NOW = 1_800_000_000;
+
public function testFromIdentityBucketsAttributesIntoOtherSectionWhenNotSensitiveNotTimestamp(): void
{
$view = UserDataNormalizer::fromIdentity(
@@ -122,6 +125,62 @@ public function testFromIdentityBuildsAvatarMonogramFromUsername(): void
);
}
+ public function testFromIdentityBuildsDefaultLabelsFromDotsAndUnderscores(): void
+ {
+ $view = UserDataNormalizer::fromIdentity(
+ ['username' => "'admin'", 'preferred.locale_key' => "'en'"],
+ null,
+ );
+
+ $other = $view->sections[1] ?? null;
+
+ 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),
+ 'Default labels must replace dots and underscores before title-casing.',
+ );
+ }
+
+ public function testFromIdentityClassifiesCaseInsensitiveAndNumericTimestampCandidates(): void
+ {
+ self::freezeTime();
+
+ $view = UserDataNormalizer::fromIdentity(
+ [
+ 'username' => "'admin'",
+ 'PASSWORD' => "'secret'",
+ 'CREATED_ON' => "'not-an-epoch'",
+ 'numeric_timestamp' => "'1700000000'",
+ 'nine_digits' => "'123456789'",
+ 'eleven_digits' => "'12345678901'",
+ 'ten_characters' => "'123456789x'",
+ ],
+ null,
+ );
+
+ $sectionKeys = [];
+
+ foreach ($view->sections as $section) {
+ $sectionKeys[$section->label] = array_map(
+ static fn(UserAttribute $attribute): string => $attribute->key,
+ $section->attributes,
+ );
+ }
+
+ self::assertSame(['PASSWORD'], $sectionKeys['Security'] ?? null, 'Sensitive matching must ignore key case.');
+ self::assertSame(
+ ['CREATED_ON', 'numeric_timestamp'],
+ $sectionKeys['Timestamps'] ?? null,
+ 'Timestamp matching must ignore key case and accept quoted ten-digit epochs.',
+ );
+ self::assertSame(
+ ['nine_digits', 'eleven_digits', 'ten_characters'],
+ $sectionKeys['Other attributes'] ?? null,
+ 'Numeric fallback must reject the wrong length and non-digit values.',
+ );
+ }
+
public function testFromIdentityFallsBackMonogramToEmailWhenUsernameMissing(): void
{
$view = UserDataNormalizer::fromIdentity(
@@ -143,14 +202,19 @@ public function testFromIdentityFallsBackMonogramToEmailWhenUsernameMissing(): v
public function testFromIdentityHumanizesTimestampsAcrossEveryRelativeBucket(): void
{
- $now = time();
+ self::freezeTime();
$view = UserDataNormalizer::fromIdentity(
[
- 'just_now_at' => "'" . ($now - 5) . "'",
- 'minute_ago_at' => "'" . ($now - 600) . "'",
- 'hour_ago_at' => "'" . ($now - 7200) . "'",
- 'day_ago_at' => "'" . ($now - 172800) . "'",
+ 'second_59_at' => "'" . (self::NOW - 59) . "'",
+ 'second_60_at' => "'" . (self::NOW - 60) . "'",
+ 'second_61_at' => "'" . (self::NOW - 61) . "'",
+ 'second_3599_at' => "'" . (self::NOW - 3599) . "'",
+ 'second_3600_at' => "'" . (self::NOW - 3600) . "'",
+ 'second_86399_at' => "'" . (self::NOW - 86399) . "'",
+ 'second_86400_at' => "'" . (self::NOW - 86400) . "'",
+ 'second_2591999_at' => "'" . (self::NOW - 2591999) . "'",
+ 'second_2592000_at' => "'" . (self::NOW - 2592000) . "'",
'old_at' => "'0'",
],
null,
@@ -168,25 +232,18 @@ 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['just_now_at'] ?? null,
- "Timestamps within the last minute must render as 'just now'.",
- );
- self::assertStringEndsWith(
- ' min ago',
- $relatives['minute_ago_at'] ?? '',
- "Timestamps under an hour must render as ' min ago'.",
- );
- self::assertStringEndsWith(
- ' h ago',
- $relatives['hour_ago_at'] ?? '',
- "Timestamps under a day must render as ' h ago'.",
- );
- self::assertStringEndsWith(
- ' d ago',
- $relatives['day_ago_at'] ?? '',
- "Timestamps under a month must render as ' d ago'.",
+ date('M j, Y · H:i', self::NOW - 2592000),
+ $relatives['second_2592000_at'] ?? null,
+ 'Exactly thirty days must use the absolute timestamp.',
);
self::assertSame(
'—',
@@ -295,6 +352,34 @@ public function testFromIdentityMapsUnknownStatusToRawValue(): void
);
}
+ public function testFromIdentityPrefersUsernameAndBuildsUnicodeMonogram(): void
+ {
+ $view = UserDataNormalizer::fromIdentity(
+ ['username' => "'éclair'", 'name' => "'Fallback name'"],
+ null,
+ );
+
+ self::assertSame(
+ 'éclair',
+ $view->hero->username,
+ 'Username must take precedence when both username and name are present.',
+ );
+ self::assertSame(
+ 'É',
+ $view->hero->monogram,
+ 'Monogram extraction and uppercasing must preserve Unicode characters.',
+ );
+ }
+
+ public function testFromIdentityPreservesPartiallyQuotedAndUnquotedValues(): void
+ {
+ foreach (["'leading" => "'leading", "trailing'" => "trailing'", 'plain' => 'plain'] as $value => $expected) {
+ $view = UserDataNormalizer::fromIdentity(['username' => $value], null);
+
+ self::assertSame($expected, $view->hero->username, 'Only matching quote wrappers may be stripped.');
+ }
+ }
+
public function testFromIdentityResolvesAttributeLabelsFromTheLabelMap(): void
{
$view = UserDataNormalizer::fromIdentity(
@@ -334,32 +419,36 @@ public function testFromIdentitySkipsEmptyValuesAsEmptyKind(): void
$view = UserDataNormalizer::fromIdentity(
[
'username' => "'admin'",
- 'password_reset_token' => 'null',
+ 'password_reset_token' => '',
+ 'secret' => 'null',
+ 'auth_key' => "'abc'",
],
null,
);
- $securityAttr = null;
+ $securityAttributes = [];
foreach ($view->sections as $section) {
if ($section->label === 'Security' && $section->attributes !== []) {
- $securityAttr = $section->attributes[0];
+ $securityAttributes = $section->attributes;
}
}
- self::assertNotNull(
- $securityAttr,
- 'Security section must surface even when value is empty.',
- );
self::assertSame(
- UserAttribute::KIND_EMPTY,
- $securityAttr->kind,
- '`null` value must collapse to the empty kind.',
- );
- self::assertSame(
- '',
- $securityAttr->displayValue,
- 'Empty kind must carry an empty display value.',
+ [
+ ['password_reset_token', UserAttribute::KIND_EMPTY, ''],
+ ['secret', UserAttribute::KIND_EMPTY, ''],
+ ['auth_key', UserAttribute::KIND_SECURITY, 'abc'],
+ ],
+ array_map(
+ static fn(UserAttribute $attribute): array => [
+ $attribute->key,
+ $attribute->kind,
+ $attribute->displayValue,
+ ],
+ $securityAttributes,
+ ),
+ 'Empty values must collapse without stopping later security attributes from rendering.',
);
}
@@ -376,4 +465,15 @@ public function testFromIdentityStripsSingleQuotesFromDisplayValue(): void
'VarDumper single-quote wrapping must be stripped.',
);
}
+
+ private static function freezeTime(): void
+ {
+ MockerState::addCondition(
+ 'PHPForge\\Debug\\Panel\\User',
+ 'time',
+ [],
+ self::NOW,
+ true,
+ );
+ }
}
diff --git a/tests/PhpInfo/PhpInfoDataNormalizerTest.php b/tests/PhpInfo/PhpInfoDataNormalizerTest.php
index 270640f..3d28d06 100644
--- a/tests/PhpInfo/PhpInfoDataNormalizerTest.php
+++ b/tests/PhpInfo/PhpInfoDataNormalizerTest.php
@@ -27,6 +27,30 @@
#[Group('phpinfo')]
final class PhpInfoDataNormalizerTest extends TestCase
{
+ /**
+ * @var list Home-directory environment variables modified by the tests.
+ */
+ private const array HOME_ENVIRONMENT_VARIABLES = ['HOME', 'USERPROFILE'];
+
+ /**
+ * @var array{HOME: string|false, USERPROFILE: string|false} Original process environment values.
+ */
+ private array $processHomeEnvironment = [
+ 'HOME' => false,
+ 'USERPROFILE' => false,
+ ];
+
+ /**
+ * @var array{
+ * HOME: array{defined: bool, value: mixed},
+ * USERPROFILE: array{defined: bool, value: mixed}
+ * } Original `$_SERVER` state.
+ */
+ private array $serverHomeEnvironment = [
+ 'HOME' => ['defined' => false, 'value' => null],
+ 'USERPROFILE' => ['defined' => false, 'value' => null],
+ ];
+
public function testCaptureBuildsViewFromBufferedPhpInfoOutput(): void
{
MockerState::addCondition(
@@ -64,6 +88,25 @@ public function testCaptureBuildsViewFromBufferedPhpInfoOutput(): void
$tile->displayValue,
'Tile value must come from the parsed body.',
);
+
+ $osTile = $this->findTileByLabel($view, 'OS');
+
+ self::assertNotNull($osTile, 'Capture must surface the runtime OS tile.');
+ self::assertSame(
+ php_uname('s') . ' ' . php_uname('r'),
+ $osTile->displayValue,
+ 'The OS tile must separate the operating-system name and release with one space.',
+ );
+ self::assertCount(
+ 1,
+ MockerState::getTraces('PHPForge\Debug\PhpInfo', 'ob_start'),
+ 'Capture must start exactly one output buffer.',
+ );
+ self::assertCount(
+ 1,
+ MockerState::getTraces('PHPForge\Debug\PhpInfo', 'phpinfo'),
+ 'Capture must invoke phpinfo exactly once.',
+ );
}
public function testCaptureFallsBackToEmptyBodyWhenOutputBufferingFails(): void
@@ -341,6 +384,43 @@ public function testFromOutputClassifiesTokenListWithShortCommaSeparatedValues()
);
}
+ public function testFromOutputCompactsExactlyThreeTrimmedTokens(): void
+ {
+ $body = <<<'HTML'
+ calendar
+ Calendar backends mysql, pgsql, sqlite
+ HTML;
+
+ $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', '');
+
+ self::assertSame(
+ [
+ [
+ 'calendar',
+ [
+ ['Calendar backends', 'mysql, pgsql, sqlite', PhpInfoTile::KIND_TOKEN_LIST, 3],
+ ],
+ ],
+ ],
+ array_map(
+ static fn(PhpInfoCompactModule $module): array => [
+ $module->title,
+ array_map(
+ static fn(PhpInfoTile $tile): array => [
+ $tile->label,
+ $tile->displayValue,
+ $tile->kind,
+ count($tile->tokens),
+ ],
+ $module->tiles,
+ ),
+ ],
+ $view->compactModules,
+ ),
+ 'A three-token fact must remain eligible for the compact Overview summary.',
+ );
+ }
+
public function testFromOutputDoesNotRedactOrdinaryModuleDirectives(): void
{
$body = <<<'HTML'
@@ -414,7 +494,7 @@ public function testFromOutputDowngradesTokenListToTextWhenTokenContainsWhitespa
public function testFromOutputExtractsConfigureCommand(): void
{
- $body = 'Configure Command ./configure --foo=bar
';
+ $body = 'Configure Command ./configure --foo=bar
';
$view = PhpInfoDataNormalizer::fromOutput(
$body,
@@ -436,7 +516,7 @@ public function testFromOutputGroupsPhpVariablesBySource(): void
$body = <<<'HTML'
PHP Variables
- Variable Value
+ Variable Value
$_REQUEST['page'] 1
$_COOKIE['theme'] dark
$_SERVER['REQUEST_METHOD'] GET
@@ -476,6 +556,21 @@ 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(
+ 'modulesHtml,
+ 'The first variable group must use an open details disclosure.',
+ );
+ self::assertLessThan(
+ strpos($view->modulesHtml, 'Cookies '),
+ strpos($view->modulesHtml, 'Request '),
+ 'Request must remain the first variable group when populated.',
+ );
+ self::assertMatchesRegularExpression(
+ '~]*>.*? .*?\$_REQUEST~s',
+ $view->modulesHtml,
+ 'Each grouped table must retain its header before its data rows.',
+ );
}
public function testFromOutputIgnoresColspanSubheadingsWhenSummarizingAModule(): void
@@ -574,6 +669,70 @@ public function testFromOutputKeepsModuleStandaloneWhenAFactValueIsEmpty(): void
);
}
+ public function testFromOutputKeepsOverviewBoundariesAndDropsEmptySections(): void
+ {
+ $body = <<<'HTML'
+ Build Date overview build
+ example
+
+ Build Date module build
+ Second value
+ Third value
+
+ HTML;
+
+ $view = PhpInfoDataNormalizer::fromOutput($body, 'x', '', '', '');
+
+ self::assertSame(
+ ['PHP version', 'Build'],
+ array_map(static fn($section): string => $section->eyebrow, $view->sections),
+ 'Only the headline section and populated Build section may remain.',
+ );
+ self::assertSame(
+ 'overview build',
+ $this->findTileByLabel($view, 'Build Date')?->displayValue,
+ 'Overview parsing must stop before module rows and trim both key and value.',
+ );
+ }
+
+ public function testFromOutputKeepsOverviewHeadingOutOfModuleParsing(): void
+ {
+ $body = <<<'HTML'
+ Overview
+
+ Build Date overview build
+ Compiler overview compiler
+ Architecture x86_64
+ Server API cli
+
+ example
+
+ First one
+ Second two
+ Third three
+ Fourth four
+
+ HTML;
+
+ $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', '');
+
+ self::assertSame(
+ ['Overview', 'example'],
+ 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"',
+ $view->modulesHtml,
+ 'The overview prefix must not be rendered again as a detailed module.',
+ );
+ self::assertSame(
+ 'overview build',
+ $this->findTileByLabel($view, 'Build Date')?->displayValue,
+ 'Overview rows must remain available to the overview section.',
+ );
+ }
+
#[DataProviderExternal(PhpInfoDataNormalizerProvider::class, 'dataTableHeads')]
public function testFromOutputLabelsDataTablesByTheirLeadingHeader(string $headers, string $expectedLabel): void
{
@@ -594,6 +753,41 @@ public function testFromOutputLabelsDataTablesByTheirLeadingHeader(string $heade
);
}
+ public function testFromOutputLabelsGenericSingleColumnTablesAsNotes(): void
+ {
+ $body = 'example ';
+
+ $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', '');
+
+ self::assertStringContainsString(
+ 'Notes 1 note ',
+ $view->modulesHtml,
+ 'A single-column table without a native caption must use the Notes label.',
+ );
+ }
+
+ 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(
+ 'Configure Command /etc/php/configure
',
+ 'x',
+ 'cli',
+ 'Linux',
+ '',
+ );
+
+ self::assertSame(
+ '/etc/php/configure',
+ $view->configureCommand,
+ 'An empty home directory must leave absolute configure-command paths untouched.',
+ );
+ }
+
public function testFromOutputMarksLongFactValuesAsWide(): void
{
$long = str_repeat('a', 73);
@@ -622,6 +816,56 @@ public function testFromOutputMarksLongFactValuesAsWide(): void
);
}
+ public function testFromOutputNormalizesFactRowWhitespaceAttributesAndUnicodeWidth(): void
+ {
+ $unicodeValue = str_repeat('é', 40);
+ $boundaryValue = str_repeat('a', 72);
+ $body = <<example
+
+ Heading
+ First one
+ Second enabled
+ Status ENABLED
+ Unicode {$unicodeValue}
+ Boundary {$boundaryValue}
+
+ HTML;
+
+ $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', '');
+
+ self::assertStringContainsString(
+ 'Heading ',
+ $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'
@@ -649,6 +893,36 @@ public function testFromOutputOmitsModulesWithoutContentRows(): void
);
}
+ public function testFromOutputOmitsWhitespaceOnlyModuleTables(): void
+ {
+ $body = 'Empty example ';
+
+ $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', '');
+
+ 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),
+ 'Whitespace-only modules must not create a TOC entry.',
+ );
+ }
+
+ public function testFromOutputPreservesRedactedRowAttributes(): void
+ {
+ $body = <<<'HTML'
+ Environment
+
+ HTML;
+
+ $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', '');
+
+ self::assertMatchesRegularExpression(
+ '~DB_PASSWORD modulesHtml,
+ 'Redaction must preserve the source row and its attributes.',
+ );
+ }
+
public function testFromOutputProducesTocEntryPerDetailedModuleH2(): void
{
$body = <<<'HTML'
@@ -726,6 +1000,62 @@ public function testFromOutputProducesUniqueSlugsForTocEntries(): void
);
}
+ public function testFromOutputQuotesHomePathAndTrimsModuleSlug(): void
+ {
+ $originalHome = $_SERVER['HOME'] ?? null;
+ $_SERVER['HOME'] = '/home/a.b';
+
+ try {
+ $view = PhpInfoDataNormalizer::fromOutput(
+ 'Configure Command /home/axb/configure
'
+ . '--example-- First one Second two Third three
',
+ 'x',
+ 'cli',
+ 'Linux',
+ '',
+ );
+ } finally {
+ if ($originalHome === null) {
+ unset($_SERVER['HOME']);
+ } else {
+ $_SERVER['HOME'] = $originalHome;
+ }
+ }
+
+ self::assertSame(
+ '/home/axb/configure',
+ $view->configureCommand,
+ 'Regex characters in the home directory must be quoted before replacement.',
+ );
+ self::assertSame(
+ ['phpinfo-overview', 'phpinfo-example'],
+ array_map(static fn(PhpInfoTocEntry $entry): string => $entry->slug, $view->tocEntries),
+ 'Module slugs must trim separator runs from both ends.',
+ );
+ }
+
+ public function testFromOutputReadsMultilineCaseInsensitiveHeadersAndCountsOnlyDataRows(): void
+ {
+ $body = <<<'HTML'
+ example
+
+
+ Variable Value
+
+ first one
+ second two
+
+ HTML;
+
+ $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', '');
+
+ self::assertStringContainsString(
+ 'Other 2 rows ',
+ $view->modulesHtml,
+ 'Multiline uppercase variable headers must enable grouping and stay out of the data count.',
+ );
+ }
+
public function testFromOutputRedactsSensitiveEnvironmentAndRuntimeVariables(): void
{
$body = <<<'HTML'
@@ -827,6 +1157,40 @@ public function testFromOutputRedactsSensitiveVariablesWhenTableHasNoVariableHea
);
}
+ 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);
+
+ PhpInfoDataNormalizer::fromOutput('', 'x', 'cli', 'Linux', '');
+
+ self::assertSame(
+ [],
+ MockerState::getTraces('PHPForge\Debug\PhpInfo', 'posix_getuid'),
+ 'The POSIX lookup must not run when posix_getuid is unavailable.',
+ );
+ }
+
+ 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);
+
+ PhpInfoDataNormalizer::fromOutput('', 'x', 'cli', 'Linux', '');
+
+ self::assertSame(
+ [],
+ MockerState::getTraces('PHPForge\Debug\PhpInfo', 'posix_getuid'),
+ 'The POSIX lookup must not run when posix_getpwuid is unavailable.',
+ );
+ }
+
public function testFromOutputResolvesHomeDirectoryFromPosixWhenEnvUnset(): void
{
$body = 'Loaded Configuration File /tmp/php.ini
';
@@ -1036,6 +1400,122 @@ public function testFromOutputSurfacesPathTokensForStandaloneAbsolutePath(): voi
);
}
+ public function testFromOutputTreatsAHeaderOnlyTwoColumnTableAsFacts(): void
+ {
+ $body = 'PHP Credits ';
+
+ $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', '');
+
+ self::assertStringContainsString(
+ 'Module information 1 value ',
+ $view->modulesHtml,
+ 'A two-column header itself carries a fact value when no known data heading is present.',
+ );
+ }
+
+ public function testFromOutputTrimsRuntimeTilesAndKeepsTokenBoundariesUnicodeAware(): void
+ {
+ $unicodeToken = str_repeat('é', 20);
+ $boundaryToken = str_repeat('a', 32);
+ $body = <<
+ Registered PHP Streams single,
+ Registered Stream Socket Transports {$boundaryToken},short
+ Registered Stream Filters {$unicodeToken},short
+
+ HTML;
+
+ $view = PhpInfoDataNormalizer::fromOutput($body, 'x', ' cli ', ' Linux ', ' 128M ');
+
+ $tiles = [];
+
+ foreach ($view->sections as $section) {
+ foreach ($section->tiles as $tile) {
+ $tiles[$tile->label] = [$tile->displayValue, $tile->kind];
+ }
+ }
+
+ self::assertSame(['cli', PhpInfoTile::KIND_TEXT], $tiles['SAPI'] ?? null, 'Runtime values must be trimmed.');
+ self::assertSame(
+ ['128M', PhpInfoTile::KIND_TEXT],
+ $tiles['Memory limit'] ?? null,
+ 'Memory limit must be trimmed before classification.',
+ );
+ self::assertSame(
+ ['single,', PhpInfoTile::KIND_TEXT],
+ $tiles['Registered PHP Streams'] ?? null,
+ 'A comma that yields only one non-empty token must remain text.',
+ );
+ self::assertSame(
+ ["{$boundaryToken},short", PhpInfoTile::KIND_TOKEN_LIST],
+ $tiles['Registered Stream Socket Transports'] ?? null,
+ 'A token of exactly 32 characters must remain a token list.',
+ );
+ self::assertSame(
+ ["{$unicodeToken},short", PhpInfoTile::KIND_TOKEN_LIST],
+ $tiles['Registered Stream Filters'] ?? null,
+ 'Token length must be measured in Unicode characters rather than bytes.',
+ );
+ }
+
+ public function testFromOutputTrimsTrailingHomeSeparators(): void
+ {
+ $originalHome = $_SERVER['HOME'] ?? null;
+ $_SERVER['HOME'] = '/home/example/';
+
+ try {
+ $view = PhpInfoDataNormalizer::fromOutput(
+ 'Loaded Configuration File /home/example/php.ini
',
+ 'x',
+ 'cli',
+ 'Linux',
+ '',
+ );
+ } finally {
+ if ($originalHome === null) {
+ unset($_SERVER['HOME']);
+ } else {
+ $_SERVER['HOME'] = $originalHome;
+ }
+ }
+
+ self::assertSame(
+ '~/php.ini',
+ $this->findTileByLabel($view, 'Loaded Configuration File')?->displayValue,
+ 'Trailing home-directory separators must not prevent path shortening.',
+ );
+ }
+
+ 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',
+ 'posix_getpwuid',
+ [1000],
+ ['dir' => '/home/example/'],
+ );
+
+ $view = PhpInfoDataNormalizer::fromOutput(
+ 'Loaded Configuration File /home/example/php.ini
',
+ 'x',
+ 'cli',
+ 'Linux',
+ '',
+ );
+
+ self::assertSame(
+ '~/php.ini',
+ $this->findTileByLabel($view, 'Loaded Configuration File')?->displayValue,
+ 'The POSIX home fallback must be returned and trimmed when both functions exist.',
+ );
+ }
+
public function testFromOutputUsesNativePhpCreditsTableTitles(): void
{
$body = <<<'HTML'
@@ -1146,6 +1626,49 @@ public function testResolveHomeDirectoryReturnsEmptyWhenEnvAndPosixUnavailable()
$tile->displayValue,
"With no home directory resolved, paths must surface verbatim (empty '\$home' skips shortening).",
);
+ self::assertSame(
+ [],
+ MockerState::getTraces('PHPForge\Debug\PhpInfo', 'posix_getuid'),
+ 'POSIX lookup must not run when neither function exists.',
+ );
+ }
+
+ /**
+ * Captures the home-directory environment state before each test.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ foreach (self::HOME_ENVIRONMENT_VARIABLES as $variable) {
+ $this->processHomeEnvironment[$variable] = getenv($variable);
+ $this->serverHomeEnvironment[$variable] = [
+ 'defined' => array_key_exists($variable, $_SERVER),
+ 'value' => $_SERVER[$variable] ?? null,
+ ];
+ }
+ }
+
+ /**
+ * Restores the home-directory environment state after each test.
+ */
+ protected function tearDown(): void
+ {
+ foreach (self::HOME_ENVIRONMENT_VARIABLES as $variable) {
+ $serverState = $this->serverHomeEnvironment[$variable];
+
+ if ($serverState['defined']) {
+ $_SERVER[$variable] = $serverState['value'];
+ } else {
+ unset($_SERVER[$variable]);
+ }
+
+ $processValue = $this->processHomeEnvironment[$variable];
+
+ putenv($processValue === false ? $variable : "{$variable}={$processValue}");
+ }
+
+ parent::tearDown();
}
private function findTileByLabel(PhpInfoView $view, string $label): PhpInfoTile|null
diff --git a/tests/PhpInfo/PhpInfoRendererTest.php b/tests/PhpInfo/PhpInfoRendererTest.php
index 6215766..72f207b 100644
--- a/tests/PhpInfo/PhpInfoRendererTest.php
+++ b/tests/PhpInfo/PhpInfoRendererTest.php
@@ -7,6 +7,7 @@
use PHPForge\Debug\PhpInfo\{
PhpInfoCompactModule,
PhpInfoDataNormalizer,
+ PhpInfoModuleGroup,
PhpInfoRenderer,
PhpInfoSection,
PhpInfoTile,
@@ -26,6 +27,25 @@
#[Group('phpinfo')]
final class PhpInfoRendererTest extends TestCase
{
+ public function testModuleGroupBucketPreservesEveryGroupAndPublicResolution(): void
+ {
+ self::assertSame('Database', PhpInfoModuleGroup::resolve('PDO'), 'Public resolution must remain callable.');
+ self::assertSame(
+ [
+ 'Core & Runtime',
+ 'Database',
+ 'Text & Localization',
+ 'Network & Security',
+ 'XML, Data & Media',
+ 'System & Compression',
+ 'Environment',
+ 'Other',
+ ],
+ array_keys(PhpInfoModuleGroup::bucket([], static fn(string $title): string => $title)),
+ 'Bucketing must preserve every group in display order even when it is empty.',
+ );
+ }
+
public function testRenderEmitsTocLinkPerEntry(): void
{
$view = $this->emptyView(
@@ -88,6 +108,16 @@ public function testRenderGroupsModulesAndFallsBackToOther(): void
$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
@@ -147,6 +177,21 @@ public function testRenderMarksOverviewAsInitialTocSelection(): void
$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
@@ -211,6 +256,11 @@ public function testRenderSearchInputCarriesFilterHooks(): void
$html,
'Search must expose an explicit clear action.',
);
+ self::assertMatchesRegularExpression(
+ '~]*\shidden(?:\s|>)~',
+ $html,
+ 'The clear action must remain hidden until the search has content.',
+ );
self::assertStringContainsString(
'data-yii-debug-phpinfo-status="true"',
$html,
@@ -448,6 +498,11 @@ public function testRenderSummarizesCompactModulesInOverview(): void
$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,
@@ -514,6 +569,12 @@ public function testRenderSurfacesVersionAndDisabledStateInCompactPills(): void
rawValue: 'disabled',
kind: PhpInfoTile::KIND_PILL_MUTED,
),
+ new PhpInfoTile(
+ label: 'Version',
+ displayValue: '1.2.3',
+ rawValue: '1.2.3',
+ kind: PhpInfoTile::KIND_TEXT,
+ ),
],
),
],
@@ -533,6 +594,11 @@ public function testRenderSurfacesVersionAndDisabledStateInCompactPills(): void
$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,
@@ -540,6 +606,33 @@ public function testRenderSurfacesVersionAndDisabledStateInCompactPills(): void
);
}
+ public function testRenderUsesUnicodeAwareStrictWideTileBoundary(): void
+ {
+ $section = new PhpInfoSection(
+ eyebrow: 'Build',
+ tiles: [
+ new PhpInfoTile('Boundary', str_repeat('a', 48), str_repeat('a', 48), PhpInfoTile::KIND_TEXT),
+ new PhpInfoTile('Unicode', str_repeat('é', 30), str_repeat('é', 30), PhpInfoTile::KIND_TEXT),
+ new PhpInfoTile('Short', 'short', 'short', PhpInfoTile::KIND_TEXT),
+ new PhpInfoTile('Path', '/x', '/x', PhpInfoTile::KIND_PATH),
+ ],
+ );
+ $html = PhpInfoRenderer::render(new PhpInfoView([$section], [], [], '', ''));
+
+ foreach (['Boundary', 'Unicode', 'Short'] as $label) {
+ self::assertMatchesRegularExpression(
+ '~\s*
\s*' . $label . '\s* ~',
+ $html,
+ "{$label} must remain a compact metric.",
+ );
+ }
+ self::assertMatchesRegularExpression(
+ '~
\s*
\s*Path\s* ~',
+ $html,
+ 'Path tiles must remain wide regardless of their short value.',
+ );
+ }
+
public function testRenderViaNormalizerSnapshotProducesExpectedAnchors(): void
{
$body = <<<'HTML'
diff --git a/tests/Storage/DebugValueTest.php b/tests/Storage/DebugValueTest.php
index b153740..07930de 100644
--- a/tests/Storage/DebugValueTest.php
+++ b/tests/Storage/DebugValueTest.php
@@ -224,6 +224,25 @@ public function testCaptureNormalizesInvalidUtf8ThrowableMessage(): void
'A binary throwable message must remain JSON-safe.',
);
}
+ public function testCapturePreservesNullAsItsOwnTaggedValue(): void
+ {
+ $value = DebugValue::capture(null);
+
+ self::assertSame(
+ 'null',
+ $value->type,
+ 'Null must retain its dedicated type tag.',
+ );
+ self::assertSame(
+ ['type' => 'null'],
+ $value->jsonSerialize(),
+ 'Serialized null must not fall through to another scalar or unsupported type.',
+ );
+ self::assertNull(
+ $value->toDisplayValue(),
+ 'The display value for the null tag must remain null.',
+ );
+ }
public function testCapturePreservesTheArrayDepthBoundary(): void
{
diff --git a/tests/Support/MockerExtension.php b/tests/Support/MockerExtension.php
index f31a2d2..a7147ef 100644
--- a/tests/Support/MockerExtension.php
+++ b/tests/Support/MockerExtension.php
@@ -59,7 +59,9 @@ public static function load(): void
{
$mocks = [];
- foreach (['function_exists', 'ob_get_clean', 'ob_start', 'phpinfo'] as $name) {
+ foreach (
+ ['function_exists', 'ob_get_clean', 'ob_start', 'phpinfo', 'posix_getpwuid', 'posix_getuid'] as $name
+ ) {
$mocks[] = [
'namespace' => 'PHPForge\Debug\PhpInfo',
'name' => $name,
@@ -73,6 +75,13 @@ public static function load(): void
];
}
+ foreach (['PHPForge\\Debug\\Panel\\Mail', 'PHPForge\\Debug\\Panel\\User'] as $namespace) {
+ $mocks[] = [
+ 'namespace' => $namespace,
+ 'name' => 'time',
+ ];
+ }
+
(new Mocker(stubPath: __DIR__ . '/mocker-stubs.php'))->load($mocks);
MockerState::saveState();
diff --git a/tests/Theme/ThemeResolverTest.php b/tests/Theme/ThemeResolverTest.php
new file mode 100644
index 0000000..c09a20b
--- /dev/null
+++ b/tests/Theme/ThemeResolverTest.php
@@ -0,0 +1,64 @@
+ ['dark']], []),
+ 'Non-string cookie values must resolve to light.',
+ );
+ self::assertSame(
+ 'light',
+ ThemeResolver::resolve([], ['yii_debug_theme' => 'solarized']),
+ 'Unknown theme names must resolve to light.',
+ );
+ }
+
+ public function testResolveMatchesDarkCaseInsensitively(): void
+ {
+ self::assertSame(
+ 'dark',
+ ThemeResolver::resolve(['yii-debug-toolbar-theme' => 'DARK'], []),
+ 'The dark keyword must match case-insensitively.',
+ );
+ }
+
+ public function testResolvePrefersTheCookieOverTheQueryParameter(): void
+ {
+ self::assertSame(
+ 'light',
+ ThemeResolver::resolve(['yii-debug-toolbar-theme' => 'light'], ['yii_debug_theme' => 'dark']),
+ 'The persisted cookie must outrank the link-time query parameter.',
+ );
+ }
+
+ public function testResolveReadsTheQueryParameterWhenNoCookieIsSet(): void
+ {
+ self::assertSame(
+ 'dark',
+ ThemeResolver::resolve([], ['yii_debug_theme' => 'dark']),
+ 'The query parameter must apply when no cookie is present.',
+ );
+ }
+}
diff --git a/tests/View/Grid/ActiveFilterBannerTest.php b/tests/View/Grid/ActiveFilterBannerTest.php
new file mode 100644
index 0000000..8149c60
--- /dev/null
+++ b/tests/View/Grid/ActiveFilterBannerTest.php
@@ -0,0 +1,75 @@
+ '404', 'url' => 'admin'],
+ static fn(array $without): string => '/debug?without=' . implode(',', $without),
+ );
+
+ self::assertStringContainsString(
+ 'href="/debug?without=statusCode"',
+ $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
+ {
+ $html = ActiveFilterBanner::render(
+ ['statusCode' => '404', 'url' => 'admin'],
+ 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.');
+ }
+
+ public function testRenderReturnsEmptyStringWhenNoFiltersAreActive(): void
+ {
+ self::assertSame(
+ '',
+ ActiveFilterBanner::render([], static fn(array $without): string => '/debug'),
+ 'No active filters must render no banner.',
+ );
+ }
+
+ public function testRenderUsesSingularLabelForOneFilter(): void
+ {
+ self::assertStringContainsString(
+ '1 filter active',
+ 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
new file mode 100644
index 0000000..be1a965
--- /dev/null
+++ b/tests/View/Grid/RowClassTest.php
@@ -0,0 +1,43 @@
+ 'yii-debug-row-danger'],
+ RowClass::for('error'),
+ "The 'error' level must alias to the danger class.",
+ );
+ }
+
+ 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.');
+ }
+
+ 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.');
+ }
+}
diff --git a/tests/View/History/HistoryCellRendererTest.php b/tests/View/History/HistoryCellRendererTest.php
new file mode 100644
index 0000000..5769083
--- /dev/null
+++ b/tests/View/History/HistoryCellRendererTest.php
@@ -0,0 +1,399 @@
+ 'abc',
+ 'method' => 'GET',
+ 'url' => '/path',
+ 'statusCode' => 200,
+ 'time' => 1_700_000_000,
+ 'ajax' => true,
+ ]);
+
+ $options = HistoryCellRenderer::buildRowAttributes($row, false);
+
+ self::assertSame(
+ [
+ 'tag' => 'abc',
+ 'method' => 'GET',
+ 'url' => '/path',
+ 'status' => '200',
+ 'time' => date('H:i:s', 1_700_000_000),
+ 'ajax' => '1',
+ ],
+ [
+ 'tag' => $options['data-yii-debug-tag'] ?? null,
+ 'method' => $options['data-yii-debug-method'] ?? null,
+ 'url' => $options['data-yii-debug-url'] ?? null,
+ 'status' => $options['data-yii-debug-status'] ?? null,
+ 'time' => $options['data-yii-debug-time'] ?? null,
+ 'ajax' => $options['data-yii-debug-ajax'] ?? null,
+ ],
+ 'Row data-yii-debug-* attributes must mirror the typed row.',
+ );
+ 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::assertStringContainsString(
+ 'yii-debug-row-danger',
+ $options['class'],
+ 'Critical status codes must surface the danger highlight class.',
+ );
+ }
+
+ public function testRenderAjaxCellMapsBoolToYesOrNo(): void
+ {
+ self::assertSame(
+ 'Yes',
+ HistoryCellRenderer::renderAjaxCell(self::row(['ajax' => true])),
+ "Boolean ajax value must map to 'Yes'.",
+ );
+ self::assertSame(
+ 'No',
+ HistoryCellRenderer::renderAjaxCell(self::row(['ajax' => false])),
+ "Boolean ajax value must map to 'No'.",
+ );
+ }
+
+ public function testRenderDurationCellFormatsMilliseconds(): void
+ {
+ self::assertSame(
+ '125 ms',
+ HistoryCellRenderer::renderDurationCell(self::row(['processingTime' => 0.125]), 0.0),
+ "Seconds must format as 'X ms'.",
+ );
+ self::assertSame(
+ '2,000 ms',
+ HistoryCellRenderer::renderDurationCell(self::row(['processingTime' => 2.0]), 0.0),
+ 'Second-scale durations must keep the thousands separator.',
+ );
+ }
+
+ public function testRenderDurationCellScalesGaugeAgainstPageMaximum(): void
+ {
+ $html = HistoryCellRenderer::renderDurationCell(self::row(['processingTime' => 0.125]), 0.25);
+
+ self::assertSame(
+ '
'
+ . '125 ms '
+ . ' '
+ . ' ',
+ $html,
+ 'Rail must sit at half the page maximum.',
+ );
+ self::assertStringContainsString(
+ '--yii-debug-gauge: 100%;',
+ HistoryCellRenderer::renderDurationCell(self::row(['processingTime' => 0.25]), 0.25),
+ 'The slowest row must fill its rail.',
+ );
+ self::assertStringContainsString(
+ '--yii-debug-gauge: 0%;',
+ HistoryCellRenderer::renderDurationCell(self::row(['processingTime' => 0.0]), 0.25),
+ 'A zero measurement must show an empty rail.',
+ );
+ }
+
+ public function testRenderDurationCellShowsNotSetWhenMissing(): void
+ {
+ $html = HistoryCellRenderer::renderDurationCell(self::row([]), 0.25);
+
+ 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
+ {
+ self::assertSame(
+ '2.000 MB',
+ HistoryCellRenderer::renderMemoryCell(self::row(['peakMemory' => 2097152]), 0),
+ "Bytes must format as 'X.XXX MB'.",
+ );
+ }
+
+ public function testRenderMemoryCellScalesGaugeAgainstPageMaximum(): void
+ {
+ $html = HistoryCellRenderer::renderMemoryCell(self::row(['peakMemory' => 2097152]), 4194304);
+
+ 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::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
+ {
+ self::assertSame(
+ '
GET ',
+ HistoryCellRenderer::renderMethodCell(self::row(['method' => 'GET'])),
+ "GET must wear the 'get' verb class.",
+ );
+ self::assertStringContainsString(
+ 'yii-debug-verb-put',
+ HistoryCellRenderer::renderMethodCell(self::row(['method' => 'PATCH'])),
+ "PATCH must share the 'put' verb hue.",
+ );
+ self::assertStringContainsString(
+ 'yii-debug-verb-other',
+ HistoryCellRenderer::renderMethodCell(self::row(['method' => 'COMMAND'])),
+ "COMMAND must fall back to the 'other' verb.",
+ );
+ }
+
+ public function testRenderMethodCellReturnsEmptyStringForUncapturedMethod(): void
+ {
+ self::assertSame(
+ '',
+ HistoryCellRenderer::renderMethodCell(self::row(['method' => ''])),
+ 'An uncaptured method must render nothing.',
+ );
+ }
+
+ public function testRenderSqlCountCellEmitsWarningGlyphWhenCountIsCritical(): void
+ {
+ $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.',
+ );
+ }
+
+ public function testRenderSqlCountCellPluralizesExcessiveCallersCount(): void
+ {
+ $row = self::row(['tag' => 'flood', 'sqlCount' => 10, 'excessiveCallersCount' => 4]);
+
+ self::assertStringContainsString(
+ '4 callers are making too many calls.',
+ HistoryCellRenderer::renderSqlCountCell($row, '/db', false, 100),
+ 'Multiple excessive callers must surface the plural tooltip form.',
+ );
+ }
+
+ public function testRenderSqlCountCellRendersPlainCountWhenNotCritical(): void
+ {
+ $row = self::row(['tag' => 'low', 'sqlCount' => 3, 'excessiveCallersCount' => 0]);
+
+ $html = HistoryCellRenderer::renderSqlCountCell($row, '/db', false, 100);
+
+ 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.',
+ HistoryCellRenderer::renderSqlCountCell($row, '/db', false, 100),
+ 'A single excessive caller must surface the singular tooltip form.',
+ );
+ }
+
+ public function testRenderStatusCellMapsCommandWithZeroToSuccess(): void
+ {
+ self::assertSame(
+ '
200 ',
+ HistoryCellRenderer::renderStatusCell(self::row(['method' => 'COMMAND', 'statusCode' => 0])),
+ "COMMAND with status '0' must display as status '200'.",
+ );
+ }
+
+ public function testRenderStatusCellMapsRangeToStatusClass(): void
+ {
+ self::assertStringContainsString(
+ 'yii-debug-badge yii-debug-status-2xx',
+ HistoryCellRenderer::renderStatusCell(self::row(['statusCode' => 200])),
+ "Status code '200' must map to '2xx'.",
+ );
+ self::assertStringContainsString(
+ 'yii-debug-status-3xx',
+ HistoryCellRenderer::renderStatusCell(self::row(['statusCode' => 301])),
+ "Status code '301' must map to '3xx'.",
+ );
+ self::assertStringContainsString(
+ 'yii-debug-status-4xx',
+ HistoryCellRenderer::renderStatusCell(self::row(['statusCode' => 404])),
+ "Status code '404' must map to '4xx'.",
+ );
+ self::assertStringContainsString(
+ 'yii-debug-status-5xx',
+ HistoryCellRenderer::renderStatusCell(self::row(['statusCode' => 500])),
+ "Status code '500' must map to '5xx'.",
+ );
+ }
+
+ public function testRenderSummaryEchoesBucketPills(): void
+ {
+ $summary = new HistorySummary(
+ totalRequests: 5,
+ statusBuckets: [
+ new HistoryStatusBucket(label: '2xx', count: 4, sampleCode: 200, variant: '2xx'),
+ new HistoryStatusBucket(label: '4xx', count: 1, sampleCode: 404, variant: '4xx'),
+ ],
+ statusCodeFilter: null,
+ );
+
+ $html = HistoryCellRenderer::renderSummary(
+ $summary,
+ ['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',
+ $html,
+ 'History summary must include the shared page-size selector.',
+ );
+ }
+
+ public function testRenderSummaryReturnsEmptyWhenNoRequestsCaptured(): void
+ {
+ $summary = new HistorySummary(totalRequests: 0, statusBuckets: [], statusCodeFilter: null);
+
+ self::assertSame(
+ '',
+ HistoryCellRenderer::renderSummary($summary, [], ''),
+ 'Empty manifest must skip the header entirely.',
+ );
+ }
+
+ 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.');
+ }
+
+ 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.');
+ }
+
+ 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.',
+ );
+ }
+
+ public function testRenderTimeCellShowsNotSetForZeroTimestamp(): void
+ {
+ self::assertStringContainsString(
+ '(not set)',
+ HistoryCellRenderer::renderTimeCell(self::row(['time' => 0])),
+ 'Zero timestamps must surface the muted placeholder.',
+ );
+ }
+
+ public function testRenderUrlCellWrapsUrlInTitleSpan(): void
+ {
+ $html = HistoryCellRenderer::renderUrlCell(self::row(['url' => 'http://example.test/path']));
+
+ 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.');
+ }
+
+ /**
+ * @param array
$overrides
+ */
+ private static function row(array $overrides = []): HistoryRow
+ {
+ return HistoryRow::fromSummary(
+ RequestSummary::fromArray(
+ [
+ 'tag' => 'tag-1',
+ 'url' => 'https://example.test/',
+ 'ajax' => false,
+ 'method' => 'GET',
+ 'ip' => '127.0.0.1',
+ 'time' => 1_700_000_000.0,
+ 'statusCode' => 200,
+ 'sqlCount' => 0,
+ 'excessiveCallersCount' => 0,
+ 'mailCount' => 0,
+ 'mailFiles' => [],
+ 'processingTime' => null,
+ 'peakMemory' => null,
+ ...$overrides,
+ ],
+ ),
+ );
+ }
+}
diff --git a/tests/View/History/HistoryRowTest.php b/tests/View/History/HistoryRowTest.php
new file mode 100644
index 0000000..2b9437d
--- /dev/null
+++ b/tests/View/History/HistoryRowTest.php
@@ -0,0 +1,101 @@
+ 1_700_000_000.0]));
+
+ self::assertSame(
+ date('H:i:s', 1_700_000_000),
+ $row->timeCompact,
+ 'A positive capture time must render as a clock string.',
+ );
+ }
+
+ public function testFromSummaryLeavesTimeCompactEmptyWhenTimeIsZero(): void
+ {
+ self::assertSame(
+ '',
+ HistoryRow::fromSummary(self::summary(['time' => 0.0]))->timeCompact,
+ 'A zero capture time must render no clock string.',
+ );
+ }
+
+ public function testFromSummaryPassesEveryFieldThroughUntouched(): void
+ {
+ $row = HistoryRow::fromSummary(
+ self::summary(
+ [
+ 'tag' => 'tag-9',
+ 'url' => 'https://example.test/orders',
+ 'ajax' => true,
+ 'method' => 'POST',
+ 'ip' => '10.0.0.1',
+ 'statusCode' => 404,
+ 'sqlCount' => 7,
+ 'excessiveCallersCount' => 2,
+ 'mailCount' => 1,
+ 'processingTime' => 0.125,
+ 'peakMemory' => 1_048_576,
+ ],
+ ),
+ );
+
+ 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.');
+ }
+
+ /**
+ * @param array $overrides
+ */
+ private static function summary(array $overrides = []): RequestSummary
+ {
+ return RequestSummary::fromArray(
+ [
+ 'tag' => 'tag-1',
+ 'url' => 'https://example.test/',
+ 'ajax' => false,
+ 'method' => 'GET',
+ 'ip' => '127.0.0.1',
+ 'time' => 1_700_000_000.0,
+ 'statusCode' => 200,
+ 'sqlCount' => 0,
+ 'excessiveCallersCount' => 0,
+ 'mailCount' => 0,
+ 'mailFiles' => [],
+ 'processingTime' => null,
+ 'peakMemory' => null,
+ ...$overrides,
+ ],
+ );
+ }
+}
diff --git a/tests/View/History/HistoryScaleTest.php b/tests/View/History/HistoryScaleTest.php
new file mode 100644
index 0000000..8f55e3f
--- /dev/null
+++ b/tests/View/History/HistoryScaleTest.php
@@ -0,0 +1,76 @@
+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.');
+ }
+
+ public function testFromModelsReturnsZeroMaximaWhenNoRowCarriesValues(): void
+ {
+ $scale = HistoryScale::fromModels(
+ [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`.');
+ }
+
+ private static function row(float|null $processingTime, int|null $peakMemory): HistoryRow
+ {
+ return HistoryRow::fromSummary(
+ RequestSummary::fromArray(
+ [
+ 'tag' => 'tag-1',
+ 'url' => 'https://example.test/',
+ 'ajax' => false,
+ 'method' => 'GET',
+ 'ip' => '127.0.0.1',
+ 'time' => 1_700_000_000.0,
+ 'statusCode' => 200,
+ 'sqlCount' => 0,
+ 'excessiveCallersCount' => 0,
+ 'mailCount' => 0,
+ 'mailFiles' => [],
+ 'processingTime' => $processingTime,
+ 'peakMemory' => $peakMemory,
+ ],
+ ),
+ );
+ }
+}
diff --git a/tests/View/History/HistorySummaryTest.php b/tests/View/History/HistorySummaryTest.php
new file mode 100644
index 0000000..f4575a7
--- /dev/null
+++ b/tests/View/History/HistorySummaryTest.php
@@ -0,0 +1,235 @@
+summary(200),
+ $this->summary(201),
+ $this->summary(304),
+ $this->summary(404),
+ $this->summary(500),
+ ],
+ );
+
+ $counts = [];
+
+ foreach ($summary->statusBuckets as $bucket) {
+ $counts[$bucket->label] = $bucket->count;
+ }
+
+ self::assertSame(
+ [
+ '2xx' => 2,
+ '3xx' => 1,
+ '4xx' => 1,
+ '5xx' => 1,
+ ],
+ $counts,
+ 'Bucket counts must reflect the manifest distribution.',
+ );
+ }
+
+ public function testFromManifestCountsTypedEntries(): void
+ {
+ $summary = HistorySummary::fromManifest(
+ [
+ $this->summary(200),
+ $this->summary(404),
+ ],
+ );
+
+ 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
+ {
+ $summary = HistorySummary::fromManifest([]);
+
+ self::assertNull(
+ $summary->statusCodeFilter,
+ 'Manifest without captured statuses must yield a null filter dropdown.',
+ );
+ }
+
+ public function testFromManifestExposesFirstSeenSampleCode(): void
+ {
+ $summary = HistorySummary::fromManifest(
+ [
+ $this->summary(201),
+ $this->summary(200),
+ ],
+ );
+
+ $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.');
+ }
+
+ public function testFromManifestKeepsStatusFamilyBoundariesExclusive(): void
+ {
+ $summary = HistorySummary::fromManifest(
+ [
+ $this->summary(0),
+ $this->summary(199),
+ $this->summary(200),
+ $this->summary(299),
+ $this->summary(300),
+ $this->summary(399),
+ $this->summary(400),
+ $this->summary(499),
+ $this->summary(500),
+ $this->summary(599),
+ $this->summary(600),
+ ],
+ );
+
+ $counts = [];
+
+ foreach ($summary->statusBuckets as $bucket) {
+ $counts[$bucket->label] = $bucket->count;
+ }
+
+ self::assertSame(
+ [
+ '2xx' => 2,
+ '3xx' => 2,
+ '4xx' => 2,
+ '5xx' => 2,
+ ],
+ $counts,
+ 'Each status family must include its lower boundary and exclude the next family boundary.',
+ );
+ self::assertSame(
+ [
+ 199 => 199,
+ 200 => 200,
+ 299 => 299,
+ 300 => 300,
+ 399 => 399,
+ 400 => 400,
+ 499 => 499,
+ 500 => 500,
+ 599 => 599,
+ 600 => 600,
+ ],
+ $summary->statusCodeFilter,
+ 'Status filter must keep positive codes while excluding an uncaptured zero status.',
+ );
+ }
+
+ public function testFromManifestMapsBucketsToVocabularyStatusClasses(): void
+ {
+ $summary = HistorySummary::fromManifest(
+ [
+ $this->summary(200),
+ $this->summary(301),
+ $this->summary(404),
+ $this->summary(500),
+ ],
+ );
+
+ $variants = [];
+
+ foreach ($summary->statusBuckets as $bucket) {
+ $variants[$bucket->label] = $bucket->variant;
+ }
+
+ self::assertSame(
+ [
+ '2xx' => '2xx',
+ '3xx' => '3xx',
+ '4xx' => '4xx',
+ '5xx' => '5xx',
+ ],
+ $variants,
+ 'Bucket variants must equal their status-class labels.',
+ );
+ }
+
+ 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.');
+ }
+
+ public function testFromManifestSkipsRequestsWithStatusBelow200(): void
+ {
+ $summary = HistorySummary::fromManifest(
+ [
+ $this->summary(100),
+ $this->summary(200),
+ ],
+ );
+
+ $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.");
+ }
+
+ public function testFromManifestSortsUniqueStatusCodes(): void
+ {
+ $summary = HistorySummary::fromManifest(
+ [
+ $this->summary(404),
+ $this->summary(200),
+ $this->summary(200),
+ $this->summary(302),
+ ],
+ );
+
+ self::assertSame(
+ [
+ 200 => 200,
+ 302 => 302,
+ 404 => 404,
+ ],
+ $summary->statusCodeFilter,
+ 'Filter map must list unique status codes in ascending order.',
+ );
+ }
+
+ private function summary(int $statusCode): RequestSummary
+ {
+ return new RequestSummary(
+ tag: 'tag-' . $statusCode,
+ url: 'https://example.test',
+ ajax: false,
+ method: 'GET',
+ ip: '127.0.0.1',
+ time: 1_700_000_000.0,
+ statusCode: $statusCode,
+ sqlCount: 0,
+ excessiveCallersCount: 0,
+ mailCount: 0,
+ mailFiles: [],
+ processingTime: null,
+ peakMemory: null,
+ );
+ }
+}
diff --git a/tests/View/Sidebar/SidebarRendererTest.php b/tests/View/Sidebar/SidebarRendererTest.php
new file mode 100644
index 0000000..51c306c
--- /dev/null
+++ b/tests/View/Sidebar/SidebarRendererTest.php
@@ -0,0 +1,255 @@
+]*title="History"[^>]*aria-current="page">/',
+ $html,
+ 'The active link must preserve its base class, tooltip, and current-page marker.',
+ );
+ }
+
+ 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,
+ '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,
+ '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
+ {
+ $view = new SidebarView(
+ snapshot: null,
+ navItems: [
+ new SidebarNavItem(
+ label: 'Request',
+ iconSvg: ' ',
+ url: '/debug/view?panel=request',
+ tooltip: 'Request',
+ isActive: false,
+ ),
+ ],
+ );
+
+ $html = SidebarRenderer::render($view);
+
+ self::assertStringContainsString(
+ 'yii-debug-nav-link-icon',
+ $html,
+ '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
+ {
+ $view = new SidebarView(snapshot: $this->snapshot(isAjax: false), navItems: []);
+
+ self::assertMatchesRegularExpression(
+ '/yii-debug-snapshot-tag[^>]*hidden/',
+ SidebarRenderer::render($view),
+ 'Non-AJAX snapshot must hide the AJAX tag.',
+ );
+ }
+
+ public function testRenderHidesTimeChipWhenTimeEmpty(): void
+ {
+ $view = new SidebarView(snapshot: $this->snapshot(time: ''), navItems: []);
+
+ self::assertMatchesRegularExpression(
+ '/yii-debug-snapshot-time[^>]*hidden/',
+ SidebarRenderer::render($view),
+ 'Empty time must hide the time chip.',
+ );
+ }
+
+ public function testRenderShowsDashWhenStatusCodeIsZero(): void
+ {
+ $view = new SidebarView(snapshot: $this->snapshot(statusCode: 0), navItems: []);
+
+ self::assertStringContainsString(
+ '>–<',
+ SidebarRenderer::render($view),
+ 'Status 0 must surface as an en-dash placeholder.',
+ );
+ }
+
+ public function testRenderSkipsSnapshotSectionWhenSnapshotIsNull(): void
+ {
+ $view = new SidebarView(snapshot: null, navItems: []);
+
+ self::assertStringNotContainsString(
+ 'yii-debug-side-section',
+ SidebarRenderer::render($view),
+ 'Null snapshot must skip the section entirely.',
+ );
+ }
+
+ public function testRenderTintsSnapshotMethodAndStatusWithVocabularyClasses(): void
+ {
+ $html = SidebarRenderer::render(new SidebarView(snapshot: $this->snapshot(), navItems: []));
+
+ self::assertStringContainsString(
+ 'class="yii-debug-snapshot-method yii-debug-verb-get"',
+ $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"',
+ SidebarRenderer::render(new SidebarView(snapshot: $this->snapshot(statusCode: 500), navItems: [])),
+ "Status '500' must wear the '5xx' status class.",
+ );
+ }
+
+ public function testRenderWiresNavigationAnchorsInViewMode(): void
+ {
+ $view = new SidebarView(snapshot: $this->snapshot(isCursor: false), navItems: []);
+
+ $html = SidebarRenderer::render($view);
+
+ self::assertStringContainsString(
+ 'aria-label="Newest captured request"',
+ $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(
+ '/]*aria-label="Newer captured request">/',
+ $html,
+ 'A missing newer capture must render a disabled button.',
+ );
+ self::assertMatchesRegularExpression(
+ '/]*href="[^"]*tag=older"[^>]*aria-label="Older captured request">/',
+ $html,
+ 'An available older capture must render an anchor to its tag.',
+ );
+ self::assertStringNotContainsString(
+ 'data-yii-debug-cursor=',
+ $html,
+ 'Navigation mode must NOT emit cursor buttons.',
+ );
+ }
+
+ private function snapshot(
+ bool $isCursor = false,
+ bool $isAjax = true,
+ int $statusCode = 200,
+ string $time = '12:34:56',
+ string $cursorInitTag = '',
+ ): SidebarSnapshot {
+ return new SidebarSnapshot(
+ title: $isCursor ? 'Newest request' : 'Current request',
+ ariaLabel: $isCursor ? 'Newest captured request' : 'Current request',
+ method: 'GET',
+ path: '/index.php',
+ fullUrl: 'http://example.test/index.php',
+ statusCode: $statusCode,
+ statusVariant: $statusCode >= 500 ? '5xx' : '2xx',
+ time: $time,
+ isAjax: $isAjax,
+ isCursor: $isCursor,
+ cursorInitTag: $cursorInitTag,
+ newestUrl: '/debug/view',
+ oldestUrl: '/debug/view?tag=oldest',
+ newerUrl: '',
+ olderUrl: '/debug/view?tag=older',
+ isNewest: true,
+ isOldest: false,
+ hasNewer: false,
+ hasOlder: true,
+ );
+ }
+}