- A Yii2 extension template to create your own Yii2 extensions
- PHPUnit, PHPStan, Codeception, and best practices ready out of the box
+ Framework-neutral contracts for portable collectors and panels rendered by the debugger frontend.
-## Features
+## A complete panel in two classes
+
+A collector buffers diagnostics while the request runs; a panel turns the stored capture into a view. Neither
+imports a debugger engine, and both share one identifier.
+
+```php
+use PHPForge\Debug\CollectorInterface;
+
+final class CacheCollector implements CollectorInterface
+{
+ /**
+ * @var list
+ */
+ private array $operations = [];
+ private bool $started = false;
+
+ public function capture(): array|null
+ {
+ return $this->started ? ['operations' => $this->operations] : null;
+ }
+
+ public function id(): string
+ {
+ return 'cache';
+ }
+
+ public function record(string $operation, string $key, string $result): void
+ {
+ if ($this->started) {
+ $this->operations[] = [$operation, $key, $result];
+ }
+ }
+
+ public function shutdown(): void
+ {
+ $this->started = false;
+ $this->operations = [];
+ }
+
+ public function startup(): void
+ {
+ $this->started = true;
+ }
+}
+```
+
+```php
+use PHPForge\Debug\{ColumnStyle, Panel, PanelView};
+
+final class CachePanel extends Panel
+{
+ protected const string ICON = 'db';
+ protected const string ID = 'cache';
+ protected const string TITLE = 'Cache';
+
+ public function present(array $data): PanelView
+ {
+ $operations = is_array($data['operations'] ?? null) ? $data['operations'] : [];
+
+ $view = PanelView::create()
+ ->summary(count($operations) === 1 ? ' operation' : ' operations', count($operations))
+ ->toolbar('Cache', count($operations))
+ ->active($operations !== []);
+
+ return $operations === []
+ ? $view->emptyState('No cache operations', 'The cache was observed, but nothing happened.')
+ : $view->table(
+ ['Operation', 'Key', 'Result'],
+ $operations,
+ collapsible: true,
+ styles: [1 => ColumnStyle::IDENTIFIER],
+ );
+ }
+}
+```
+
+Call `record()` from the application service that already knows about the operation. `capture()` returns `null` when
+there is nothing to report, and an array otherwise: an empty array is an observed empty request, not absence. The
+host encodes that array strictly, so omit secrets and keep values JSON-encodable.
-
-
-
-
+## Register it
-## Quick start
+Merge these fragments into an application whose debugger is already enabled. The collector's `id()` and the panel's
+`ID` must match: that is how the host pairs a capture with its panel.
-### Installation
+```php
+// Yii2: inside the YII_ENV_DEV guard, preserving the existing module settings.
+$collector = new CacheCollector();
-```bash
-composer require github_username/github_repository-name
+$config['modules']['debug']['collectors'][] = $collector;
+$config['modules']['debug']['panels'][] = new CachePanel();
```
-### Basic Usage
+```php
+// Yii3: return the extended registry from the application's development DI factory.
+$collector = new CacheCollector();
+
+$registry = $registry
+ ->withCollector($collector)
+ ->withPanel(new CachePanel());
+```
-Describe how to use your extension in a basic way.
+Both hosts derive the IDs and wrap the portable objects internally. No catalog entry, icon enum, storage dispatch
+entry, or change to an official package is needed. Inject the same `$collector` into the application service that
+calls `record()`.
+
+A runnable version of this example, capturing through PSR-3 instead of a direct call, lives in
+[tests/Support](tests/Support); `python3 tools/check-consumer.py` installs it as an independent Composer package and
+replays a stored capture with no debugger host present.
+
+## Presentation vocabulary
+
+Five types are published: `CollectorInterface`, `Panel`, `PanelView`, `Tone`, and `ColumnStyle`. Everything a panel can
+display is a `PanelView` method, so there is no value class to import and no shape to build by hand.
+
+```php
+use PHPForge\Debug\{ColumnStyle, PanelView, Tone};
+
+PanelView::create()
+ ->summary(' props', 2)
+ ->toolbar('Props', 2)
+ ->heading('Props', section: true)
+ ->overview(['Component' => 'Site', 'State' => PanelView::badge('shared', Tone::INFO)])
+ ->paragraph('Rendered by ', PanelView::code('Inertia::render()'))
+ ->callout(Tone::WARNING, 'Runtime inspection is unavailable.')
+ ->table(['Prop', 'Value'], [['auth', PanelView::value(['id' => 1])]], styles: [0 => ColumnStyle::IDENTIFIER])
+ ->group('Component', PanelView::create()->paragraph('Nested content'))
+ ->emptyState('No operations', 'The cache was observed, but nothing happened.')
+ ->disclosure('Raw payload', $json)
+ ->active(true);
+```
-## Documentation
+Plain scalars and `null` become text. `PanelView::text()`, `::strong()`, `::code()`, `::preview()`, `::badge()`, and
+`::value()` produce validated inline values accepted wherever a scalar is accepted. Every method validates its
+arguments and rejects invalid input with an explicit `InvalidArgumentException`. The host reads the finished
+description through `summaryMetrics()`, `toolbarMetrics()`, `blocks()`, and `isActive()`.
-For detailed configuration options and advanced usage.
+## Verification
-- ๐ [Installation Guide](docs/installation.md)
-- โ๏ธ [Configuration Reference](docs/configuration.md)
-- ๐ก [Usage Examples](docs/examples.md)
-- ๐งช [Testing Guide](docs/testing.md)
-- ๐ ๏ธ [Development Guide](docs/development.md)
+[docs/testing.md](docs/testing.md) lists the Composer scripts and the two isolated-consumer checks.
## Package information
[](https://www.php.net/releases/8.3/en.php)
-[](https://github.com/yiisoft/yii2/tree/22.0)
-[](https://packagist.org/packages/yii2-extensions/template)
-[](https://packagist.org/packages/yii2-extensions/template)
+[](https://github.com/php-forge/debug/actions/workflows/static.yml)
+[](https://packagist.org/packages/php-forge/debug)
+[](https://packagist.org/packages/php-forge/debug)
-## Project status
+## Code quality
-[](https://codecov.io/github/yii2-extensions/template)
-[](https://github.com/yii2-extensions/template/actions/workflows/static.yml)
-[](https://github.com/yii2-extensions/template/actions/workflows/quality.yml)
-[](https://github.styleci.io/repos/698621511?branch=main)
+[](https://codecov.io/gh/php-forge/debug)
+[](https://github.com/php-forge/debug/actions/workflows/quality.yml)
+[](https://github.styleci.io/repos/php-forge/debug?branch=main)
-## Our social networks
+## Social networks
[](https://x.com/Terabytesoftw)
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000..585dd38
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,12 @@
+coverage:
+ precision: 2
+ round: down
+ status:
+ project:
+ default:
+ target: 100%
+ threshold: 0%
+ patch:
+ default:
+ target: 100%
+ threshold: 0%
diff --git a/composer.json b/composer.json
index 66ae883..059472d 100644
--- a/composer.json
+++ b/composer.json
@@ -1,16 +1,17 @@
{
- "name": "yii2-extensions/template",
+ "name": "php-forge/debug",
"type": "library",
- "description": "_____",
+ "description": "Framework-neutral contracts and declarative presentation models for debugger extensions.",
"keywords": [
- "_____"
+ "debug",
+ "interop",
+ "panel"
],
"license": "BSD-3-Clause",
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
- "php": ">=8.3",
- "yiisoft/yii2": "^22"
+ "php": ">=8.3"
},
"require-dev": {
"infection/infection": "^0.35",
@@ -20,29 +21,29 @@
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0.3",
"phpunit/phpunit": "^12.5",
- "yii2-extensions/phpstan": "^0.4"
+ "psr/log": "^3.0",
+ "phpstan/phpstan": "^2.2"
},
"autoload": {
"psr-4": {
- "yii\\template\\": "src"
+ "PHPForge\\Debug\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
- "yii\\template\\tests\\": "tests"
+ "PHPForge\\Debug\\Tests\\": "tests/"
}
},
"extra": {
"branch-alias": {
- "dev-main": "1.0.x-dev"
+ "dev-main": "0.1.x-dev"
}
},
"config": {
"sort-packages": true,
"allow-plugins": {
"infection/extension-installer": true,
- "phpstan/extension-installer": true,
- "yiisoft/yii2-composer": true
+ "phpstan/extension-installer": true
}
},
"scripts": {
diff --git a/docs/configuration.md b/docs/configuration.md
deleted file mode 100644
index e1cdf21..0000000
--- a/docs/configuration.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Configuration reference
-
-## Overview
-
-## Basic configuration
-
-## Next steps
-
-- ๐ก [Usage Examples](examples.md)
-- ๐งช [Testing Guide](testing.md)
diff --git a/docs/development.md b/docs/development.md
deleted file mode 100644
index 0e75489..0000000
--- a/docs/development.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# Development
-
-This document describes development workflows and maintenance tasks for the project.
-
-## Sync Metadata
-
-To keep configuration files synchronized with the latest template updates, use the `sync-metadata` command. This command
-downloads the latest configuration files from the template repository.
-
-```bash
-composer sync-metadata
-```
-
-### Updated Files
-
-This command updates the following configuration files:
-
-| File | Purpose |
-| ------------------ | -------------------------------------------- |
-| `.editorconfig` | Editor settings and code style configuration |
-| `.gitattributes` | Git attributes and file handling rules |
-| `.gitignore` | Git ignore patterns and exclusions |
-| `.styleci.yml` | StyleCI code style analysis configuration |
-| `infection.json5` | Infection mutation testing configuration |
-| `phpstan.neon` | PHPStan static analysis configuration |
-| `phpunit.xml.dist` | PHPUnit test configuration |
-
-### When to Run
-
-Run this command in the following scenarios:
-
-- **Periodic Updates** - Monthly or quarterly to benefit from template improvements.
-- **After Template Updates** - When the template repository has new configuration improvements.
-- **Before Major Releases** - Ensure your project uses the latest best practices.
-- **When Issues Occur** - If configuration files become outdated or incompatible.
-
-### Important Notes
-
-- This command overwrites existing configuration files with the latest versions from the template.
-- Ensure you have committed any custom configuration changes before running this command.
-- Review the updated files after syncing to ensure they work with your specific project needs.
-- Some projects may require customizations after syncing configuration files.
diff --git a/docs/examples.md b/docs/examples.md
deleted file mode 100644
index 6e994d5..0000000
--- a/docs/examples.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Usage examples
-
-## Next steps
-
-- ๐ [Installation Guide](installation.md)
-- โ๏ธ [Configuration Guide](configuration.md)
-- ๐งช [Testing Guide](testing.md)
diff --git a/docs/installation.md b/docs/installation.md
deleted file mode 100644
index 0e56654..0000000
--- a/docs/installation.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Installation guide
-
-## System requirements
-
-- [`PHP`](https://www.php.net/downloads) 8.1 or higher.
-- [`Composer`](https://getcomposer.org/download/) for dependency management.
-- [`Yii2`](https://github.com/yiisoft/yii2) 2.0.53+ or 22.x.
-
-## Installation
-
-### Method 1: Using [Composer](https://getcomposer.org/download/) (recommended)
-
-Install the extension.
-
-```bash
-composer require github_username/github_repository-name
-```
-
-### Method 2: Manual installation
-
-Add to your `composer.json`.
-
-```json
-{
- "require": {
- "github_username/github_repository-name": "^1.0"
- }
-}
-```
-
-Then run.
-
-```bash
-composer update
-```
-
-## Next steps
-
-Once the installation is complete.
-
-- โ๏ธ [Configuration Reference](configuration.md)
-- ๐ก [Usage Examples](examples.md)
-- ๐งช [Testing Guide](testing.md)
diff --git a/docs/svgs/features-mobile.svg b/docs/svgs/features-mobile.svg
deleted file mode 100644
index 58e8ebf..0000000
--- a/docs/svgs/features-mobile.svg
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
diff --git a/docs/svgs/features.svg b/docs/svgs/features.svg
deleted file mode 100644
index 488018a..0000000
--- a/docs/svgs/features.svg
+++ /dev/null
@@ -1,56 +0,0 @@
-
diff --git a/docs/testing.md b/docs/testing.md
index e0e9d88..d4f6bc5 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -19,6 +19,12 @@ Run Rector to apply automated code refactoring.
composer rector
```
+Validate Rector without modifying files.
+
+```bash
+composer rector -- --dry-run
+```
+
## Coding standards (ECS)
Run Easy Coding Standard (ECS) and apply fixes.
@@ -80,3 +86,7 @@ Run PHPStan with a different memory limit.
```bash
composer static -- --memory-limit=512M
```
+
+## Next steps
+
+- ๐ [Readme](../README.md)
diff --git a/phpstan.neon b/phpstan.neon
index 735031c..63c0f31 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -2,9 +2,6 @@ includes:
- phar://phpstan.phar/conf/bleedingEdge.neon
parameters:
- bootstrapFiles:
- - tests/bootstrap.php
-
level: max
paths:
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 33b6694..4bdb74b 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -11,7 +11,7 @@
stopOnFailure="false"
>
-
+ tests
diff --git a/src/CollectorInterface.php b/src/CollectorInterface.php
new file mode 100644
index 0000000..86bab9d
--- /dev/null
+++ b/src/CollectorInterface.php
@@ -0,0 +1,40 @@
+|null Captured payload, or `null` when no capture is available.
+ */
+ public function capture(): array|null;
+
+ /**
+ * Returns the stable identifier used to associate captured data with its panel.
+ *
+ * @return string Provider-owned capture identifier.
+ */
+ public function id(): string;
+
+ /**
+ * Stops request-scoped collection and releases its state and instrumentation.
+ */
+ public function shutdown(): void;
+
+ /**
+ * Starts request-scoped collection before the observed application work.
+ */
+ public function startup(): void;
+}
diff --git a/src/ColumnStyle.php b/src/ColumnStyle.php
new file mode 100644
index 0000000..5a96c23
--- /dev/null
+++ b/src/ColumnStyle.php
@@ -0,0 +1,41 @@
+ $data Provider-owned captured data, decoded by the integration when necessary.
+ *
+ * @return PanelView Panel content, metrics, and activity described for the host frontend.
+ */
+ abstract public function present(array $data): PanelView;
+
+ /**
+ * Returns the icon identifier declared by the extension.
+ *
+ * @throws InvalidArgumentException If the icon identifier is empty.
+ *
+ * @return string Host-interpreted icon identifier.
+ */
+ final public function icon(): string
+ {
+ return self::required(static::ICON, 'ICON');
+ }
+
+ /**
+ * Returns the stable panel identifier declared by the extension.
+ *
+ * @throws InvalidArgumentException If the panel identifier is empty.
+ *
+ * @return string Identifier used to associate the panel with captured data.
+ */
+ final public function id(): string
+ {
+ return self::required(static::ID, 'ID');
+ }
+
+ /**
+ * Returns the navigation title declared by the extension.
+ *
+ * @throws InvalidArgumentException If the panel title is empty.
+ *
+ * @return string Human-readable panel title.
+ */
+ final public function name(): string
+ {
+ return self::required(static::TITLE, 'TITLE');
+ }
+
+ /**
+ * Rejects an empty metadata value and identifies the missing definition field.
+ *
+ * @param string $value Metadata value to validate.
+ * @param string $field Constant name included in the validation error.
+ *
+ * @throws InvalidArgumentException If the metadata value is empty.
+ *
+ * @return string Unmodified metadata value.
+ */
+ private static function required(string $value, string $field): string
+ {
+ if ($value === '') {
+ throw new InvalidArgumentException(
+ "A panel definition must declare {$field}.",
+ );
+ }
+ return $value;
+ }
+}
diff --git a/src/PanelView.php b/src/PanelView.php
new file mode 100644
index 0000000..0efd57b
--- /dev/null
+++ b/src/PanelView.php
@@ -0,0 +1,673 @@
+}
+ * @phpstan-type GroupBlock array{kind: 'group', label: string, content: PanelView}
+ * @phpstan-type OverviewBlock array{kind: 'overview', fields: list, compact: bool}
+ * @phpstan-type ParagraphBlock array{kind: 'paragraph', content: list, tone: Tone|null}
+ * @phpstan-type TableBlock array{
+ * kind: 'table',
+ * headers: list,
+ * rows: list>,
+ * styles: array,
+ * collapsible: bool
+ * }
+ * @phpstan-type Block array{
+ * kind: 'disclosure',
+ * title: string,
+ * content: string
+ * }|array{kind: 'heading', title: string, section: bool}|EmptyStateBlock|GroupBlock|OverviewBlock|ParagraphBlock|TableBlock
+ */
+final readonly class PanelView implements JsonSerializable
+{
+ /**
+ * @param list $summary Summary metrics in display order.
+ * @param list $blocks Panel content blocks in display order.
+ * @param list $toolbar Toolbar metrics in display order, separate from the summary.
+ * @param bool $active Whether the panel is marked active for host navigation.
+ */
+ private function __construct(
+ private array $summary,
+ private array $blocks,
+ private array $toolbar,
+ private bool $active,
+ ) {}
+
+ /**
+ * Changes the activity flag without altering content or metrics.
+ *
+ * @param bool $active Whether the panel is marked active for host navigation.
+ *
+ * @return self New view with the requested activity flag.
+ */
+ public function active(bool $active): self
+ {
+ return new self(
+ $this->summary,
+ $this->blocks,
+ $this->toolbar,
+ $active,
+ );
+ }
+
+ /**
+ * Creates an inline status label with a semantic tone.
+ *
+ * @param string $label Badge text; the host escapes it.
+ * @param Tone $tone Semantic tone interpreted by the host frontend.
+ *
+ * @return BadgeInline Inline badge accepted by every content method.
+ */
+ public static function badge(string $label, Tone $tone = Tone::MUTED): array
+ {
+ return [
+ 'kind' => 'badge',
+ 'label' => $label,
+ 'tone' => $tone,
+ ];
+ }
+
+ /**
+ * Returns the content blocks for the host renderer.
+ *
+ * @return list Validated content blocks in display order.
+ */
+ public function blocks(): array
+ {
+ return $this->blocks;
+ }
+
+ /**
+ * Appends a paragraph with a semantic callout tone.
+ *
+ * @param Tone $tone Callout tone interpreted by the host frontend.
+ * @param mixed ...$content Ordered factory-produced inline values, scalars, or `null`.
+ *
+ * @throws InvalidArgumentException If an argument is not an accepted inline value.
+ *
+ * @return self New view with the callout appended.
+ */
+ public function callout(Tone $tone, mixed ...$content): self
+ {
+ return $this->append(self::paragraphBlock($content, $tone));
+ }
+
+ /**
+ * Creates inline text presented as source code.
+ *
+ * @param string $value Text content; the host escapes it.
+ *
+ * @return TextInline Inline text accepted by every content method.
+ */
+ public static function code(string $value): array
+ {
+ return [
+ 'kind' => 'text',
+ 'value' => $value,
+ 'style' => 'code',
+ ];
+ }
+
+ /**
+ * Creates an active view without content or metrics as the starting point for fluent composition.
+ *
+ * @return self Empty active view.
+ */
+ public static function create(): self
+ {
+ return new self(
+ [],
+ [],
+ [],
+ true,
+ );
+ }
+
+ /**
+ * Appends a titled plain-text disclosure.
+ *
+ * @param string $title Disclosure label.
+ * @param string $content Plain-text payload, preserved without formatting.
+ *
+ * @return self New view with the disclosure appended.
+ */
+ public function disclosure(string $title, string $content): self
+ {
+ return $this->append(['kind' => 'disclosure', 'title' => $title, 'content' => $content]);
+ }
+
+ /**
+ * Appends an explicit empty state whose arguments each describe one paragraph.
+ *
+ * @param string $title Empty-state heading.
+ * @param mixed ...$paragraphs Ordered explanations, each one paragraph: a scalar, a factory-produced inline value,
+ * or a list of inline values and scalars.
+ *
+ * @throws InvalidArgumentException If a paragraph is neither an inline value nor a list of them.
+ *
+ * @return self New view with the empty state appended.
+ */
+ public function emptyState(string $title, mixed ...$paragraphs): self
+ {
+ $content = [];
+
+ foreach ($paragraphs as $paragraph) {
+ $content[] = self::paragraphOf($paragraph);
+ }
+
+ return $this->append(['kind' => 'emptyState', 'title' => $title, 'paragraphs' => $content]);
+ }
+
+ /**
+ * Groups only the child's content; metrics and activity belong to the root view.
+ *
+ * @param string $label Accessible group label.
+ * @param self $content Child view contributing only its ordered content blocks.
+ *
+ * @return self New view with the group appended and the current metrics and activity preserved.
+ */
+ public function group(string $label, self $content): self
+ {
+ return $this->append(['kind' => 'group', 'label' => $label, 'content' => $content]);
+ }
+
+ /**
+ * Appends an ordinary or section-level heading.
+ *
+ * @param string $title Heading text.
+ * @param bool $section Whether to request section-heading presentation from the host.
+ *
+ * @return self New view with the heading appended.
+ */
+ public function heading(string $title, bool $section = false): self
+ {
+ return $this->append(['kind' => 'heading', 'title' => $title, 'section' => $section]);
+ }
+
+ /**
+ * Reports whether the panel is marked active for host navigation.
+ *
+ * @return bool Activity flag.
+ */
+ public function isActive(): bool
+ {
+ return $this->active;
+ }
+
+ /**
+ * Returns the complete description for inspection, fixtures, and diffing.
+ *
+ * @return array{summary: list, blocks: list, toolbar: list, active: bool} Description.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'summary' => $this->summary,
+ 'blocks' => $this->blocks,
+ 'toolbar' => $this->toolbar,
+ 'active' => $this->active,
+ ];
+ }
+
+ /**
+ * Appends labeled overview fields, using array keys as labels.
+ *
+ * Labels are unique because they are array keys; repeat a value under a different label instead.
+ *
+ * @param array $values Labeled values in display order, each an inline value or a scalar.
+ * @param bool $compact Whether to request compact presentation from the host.
+ *
+ * @throws InvalidArgumentException If a value is not an accepted inline value.
+ *
+ * @return self New view with the overview appended.
+ */
+ public function overview(array $values, bool $compact = false): self
+ {
+ $fields = [];
+
+ foreach ($values as $label => $value) {
+ $fields[] = ['label' => (string) $label, 'value' => self::inline($value)];
+ }
+
+ return $this->append(['kind' => 'overview', 'fields' => $fields, 'compact' => $compact]);
+ }
+
+ /**
+ * Appends an ordinary paragraph, converting plain values to text.
+ *
+ * @param mixed ...$content Ordered factory-produced inline values, scalars, or `null`.
+ *
+ * @throws InvalidArgumentException If an argument is not an accepted inline value.
+ *
+ * @return self New view with the paragraph appended.
+ */
+ public function paragraph(mixed ...$content): self
+ {
+ return $this->append(self::paragraphBlock($content, null));
+ }
+
+ /**
+ * Creates inline text the host may clamp behind its standard expand control.
+ *
+ * @param string $value Text content; the host escapes it.
+ *
+ * @return TextInline Inline text accepted by every content method.
+ */
+ public static function preview(string $value): array
+ {
+ return [
+ 'kind' => 'text',
+ 'value' => $value,
+ 'style' => 'preview',
+ ];
+ }
+
+ /**
+ * Creates emphasized inline text.
+ *
+ * @param string $value Text content; the host escapes it.
+ *
+ * @return TextInline Inline text accepted by every content method.
+ */
+ public static function strong(string $value): array
+ {
+ return [
+ 'kind' => 'text',
+ 'value' => $value,
+ 'style' => 'strong',
+ ];
+ }
+
+ /**
+ * Adds a metric whose label follows its value. Include any intended spacing in the label.
+ *
+ * @param string $label Suffix displayed after the metric value.
+ * @param string|int|float $value Metric value converted to text.
+ * @param bool $emphasized Whether to request emphasis for the value.
+ *
+ * @return self New view with the summary metric appended.
+ */
+ public function summary(string $label, string|int|float $value, bool $emphasized = true): self
+ {
+ $metric = [
+ 'label' => $label,
+ 'value' => $emphasized ? self::strong((string) $value) : self::text((string) $value),
+ ];
+
+ return new self(
+ [...$this->summary, $metric],
+ $this->blocks,
+ $this->toolbar,
+ $this->active,
+ );
+ }
+
+ /**
+ * Returns the summary metrics for the host renderer.
+ *
+ * @return list Validated summary metrics in display order.
+ */
+ public function summaryMetrics(): array
+ {
+ return $this->summary;
+ }
+
+ /**
+ * Appends a table whose columns share a semantic style.
+ *
+ * @param array $headers Plain-text column headings in display order.
+ * @param array $rows Rows of inline or plain values in display order.
+ * @param bool $collapsible Whether the host may collapse the table.
+ * @param array $styles Optional {@see ColumnStyle} cases keyed by column index.
+ *
+ * @throws InvalidArgumentException If a header, row width, cell, or column style is invalid.
+ *
+ * @return self New view with the table appended.
+ */
+ public function table(array $headers, array $rows, bool $collapsible = false, array $styles = []): self
+ {
+ $columns = self::headers($headers);
+
+ return $this->append(
+ [
+ 'kind' => 'table',
+ 'headers' => $columns,
+ 'rows' => self::rows($rows, count($columns)),
+ 'styles' => self::styles($styles, count($columns)),
+ 'collapsible' => $collapsible,
+ ],
+ );
+ }
+
+ /**
+ * Creates plain inline text.
+ *
+ * @param string $value Text content; the host escapes it.
+ *
+ * @return TextInline Inline text accepted by every content method.
+ */
+ public static function text(string $value): array
+ {
+ return [
+ 'kind' => 'text',
+ 'value' => $value,
+ 'style' => 'plain',
+ ];
+ }
+
+ /**
+ * Adds a toolbar metric independently of the panel summary.
+ *
+ * @param string $label Toolbar metric label.
+ * @param string|int|float $value Metric value converted to text.
+ *
+ * @return self New view with the toolbar metric appended.
+ */
+ public function toolbar(string $label, string|int|float $value): self
+ {
+ $metric = [
+ 'label' => $label,
+ 'value' => self::text((string) $value),
+ ];
+
+ return new self(
+ $this->summary,
+ $this->blocks,
+ [...$this->toolbar, $metric],
+ $this->active,
+ );
+ }
+
+ /**
+ * Returns the toolbar metrics for the host renderer.
+ *
+ * @return list Validated toolbar metrics in display order.
+ */
+ public function toolbarMetrics(): array
+ {
+ return $this->toolbar;
+ }
+
+ /**
+ * Creates a captured diagnostic value the host formats and escapes.
+ *
+ * @param mixed $value Diagnostic value, preserved without conversion.
+ * @param bool $typeOnly Whether to show only the value's type instead of its contents.
+ *
+ * @return ValueInline Inline value accepted by every content method.
+ */
+ public static function value(mixed $value, bool $typeOnly = false): array
+ {
+ return [
+ 'kind' => 'value',
+ 'value' => $value,
+ 'typeOnly' => $typeOnly,
+ ];
+ }
+
+ /**
+ * Appends a content block while retaining metrics and activity.
+ *
+ * @param Block $block Content block placed after the existing blocks.
+ *
+ * @return self New view containing the additional block.
+ */
+ private function append(array $block): self
+ {
+ return new self(
+ $this->summary,
+ [...$this->blocks, $block],
+ $this->toolbar,
+ $this->active,
+ );
+ }
+
+ /**
+ * Rejects column headings that are not plain strings.
+ *
+ * @param array $headers Column headings to validate.
+ *
+ * @throws InvalidArgumentException If a heading is not a string.
+ *
+ * @return list Validated column headings in display order.
+ */
+ private static function headers(array $headers): array
+ {
+ $columns = [];
+
+ foreach ($headers as $header) {
+ if (is_string($header) === false) {
+ throw new InvalidArgumentException(
+ 'Debug panel table headers must be plain strings.',
+ );
+ }
+
+ $columns[] = $header;
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Retains inline values and converts scalars and `null` to text.
+ *
+ * @param mixed $value Inline value or plain value to normalize.
+ *
+ * @throws InvalidArgumentException If the value is not an accepted inline input.
+ *
+ * @return Inline Normalized inline value.
+ */
+ private static function inline(mixed $value): array
+ {
+ return match (true) {
+ is_array($value) => self::inlineShape($value),
+ $value === null => self::text('null'),
+ $value === true => self::text('true'),
+ $value === false => self::text('false'),
+ is_string($value) => self::text($value),
+ is_int($value), is_float($value) => self::text((string) $value),
+ default => throw self::unsupportedInline($value),
+ };
+ }
+
+ /**
+ * Rebuilds a factory-produced inline value, rejecting every other array.
+ *
+ * @param array $value Candidate inline value.
+ *
+ * @throws InvalidArgumentException If the array was not produced by an inline factory.
+ *
+ * @return Inline Validated inline value.
+ */
+ private static function inlineShape(array $value): array
+ {
+ $kind = $value['kind'] ?? null;
+ $label = $value['label'] ?? null;
+ $tone = $value['tone'] ?? null;
+
+ if ($kind === 'badge' && is_string($label) && $tone instanceof Tone) {
+ return ['kind' => 'badge', 'label' => $label, 'tone' => $tone];
+ }
+
+ $text = $value['value'] ?? null;
+ $style = $value['style'] ?? null;
+
+ if ($kind === 'text' && is_string($text) && in_array($style, ['code', 'plain', 'preview', 'strong'], true)) {
+ return [
+ 'kind' => 'text',
+ 'value' => $text,
+ 'style' => $style,
+ ];
+ }
+
+ $typeOnly = $value['typeOnly'] ?? null;
+
+ if ($kind === 'value' && array_key_exists('value', $value) && is_bool($typeOnly)) {
+ return [
+ 'kind' => 'value',
+ 'value' => $value['value'],
+ 'typeOnly' => $typeOnly,
+ ];
+ }
+
+ throw self::unsupportedInline($value);
+ }
+
+ /**
+ * Builds a paragraph block from ordered inline inputs.
+ *
+ * @param array $content Inline values or plain values in display order.
+ * @param Tone|null $tone Callout tone, or `null` for an ordinary paragraph.
+ *
+ * @throws InvalidArgumentException If an item is not an accepted inline value.
+ *
+ * @return ParagraphBlock Validated paragraph block.
+ */
+ private static function paragraphBlock(array $content, Tone|null $tone): array
+ {
+ $inline = [];
+
+ foreach ($content as $value) {
+ $inline[] = self::inline($value);
+ }
+
+ return [
+ 'kind' => 'paragraph',
+ 'content' => $inline,
+ 'tone' => $tone,
+ ];
+ }
+
+ /**
+ * Builds one paragraph from a string, a single inline value, or a list of inline values.
+ *
+ * @param mixed $paragraph Paragraph description.
+ *
+ * @throws InvalidArgumentException If the description is neither an inline value nor a list of them.
+ *
+ * @return ParagraphBlock Validated paragraph block.
+ */
+ private static function paragraphOf(mixed $paragraph): array
+ {
+ if (is_array($paragraph) === false || array_key_exists('kind', $paragraph)) {
+ return self::paragraphBlock([$paragraph], null);
+ }
+
+ if (array_is_list($paragraph) === false) {
+ throw new InvalidArgumentException(
+ 'Debug panel paragraphs must be scalars, inline values, or lists of them.',
+ );
+ }
+
+ return self::paragraphBlock($paragraph, null);
+ }
+
+ /**
+ * Rejects rows that are not lists of the table's width.
+ *
+ * @param array $rows Rows to validate.
+ * @param int $columns Number of declared columns.
+ *
+ * @throws InvalidArgumentException If a row is not a list or its width differs from the headers.
+ *
+ * @return list> Validated rows in display order.
+ */
+ private static function rows(array $rows, int $columns): array
+ {
+ $result = [];
+
+ foreach ($rows as $row) {
+ if (is_array($row) === false || array_is_list($row) === false || count($row) !== $columns) {
+ throw new InvalidArgumentException(
+ 'Debug panel table rows must be lists whose width matches the headers.',
+ );
+ }
+
+ $cells = [];
+
+ foreach ($row as $value) {
+ $cells[] = self::inline($value);
+ }
+
+ $result[] = $cells;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Rejects column styles that do not address a declared column.
+ *
+ * @param array $styles Column styles keyed by column index.
+ * @param int $columns Number of declared columns.
+ *
+ * @throws InvalidArgumentException If a key is not an existing column index or a value is not a style.
+ *
+ * @return array Validated column styles.
+ */
+ private static function styles(array $styles, int $columns): array
+ {
+ $result = [];
+
+ foreach ($styles as $column => $style) {
+ if (is_int($column) === false || $column < 0 || $column >= $columns) {
+ throw new InvalidArgumentException(
+ 'Debug panel column styles must be keyed by an existing column index.',
+ );
+ }
+
+ if ($style instanceof ColumnStyle === false) {
+ throw new InvalidArgumentException(
+ 'Debug panel column styles must be ' . ColumnStyle::class . ' cases.',
+ );
+ }
+
+ $result[$column] = $style;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Builds the rejection naming the value that cannot be displayed inline.
+ *
+ * @param mixed $value Rejected value.
+ *
+ * @return InvalidArgumentException Rejection carrying the received type.
+ */
+ private static function unsupportedInline(mixed $value): InvalidArgumentException
+ {
+ return new InvalidArgumentException(
+ 'Debug panel inline content must be a scalar, null, or a PanelView inline value. Got '
+ . get_debug_type($value) . '.',
+ );
+ }
+}
diff --git a/src/Tone.php b/src/Tone.php
new file mode 100644
index 0000000..ed35a1c
--- /dev/null
+++ b/src/Tone.php
@@ -0,0 +1,36 @@
+id(), new \Psr\Log\NullLogger());
+ $cache = new Cache($collector);
+
+ $collector->startup();
+
+ $cache->get('repeat');
+ $cache->get('repeat');
+ $cache->set('repeat', 'private');
+ $cache->get('repeat');
+ $cache->get('repeat');
+ $cache->get('repeat');
+
+ $payload = $collector->capture();
+
+ self::assertNotNull(
+ $payload,
+ 'An active collector must report a capture.',
+ );
+
+ $collector->shutdown();
+
+ $view = $panel->present($payload);
+
+ self::assertSame(
+ '3',
+ $view->toolbarMetrics()[0]['value']['value'] ?? null,
+ 'Hits must count every reuse.',
+ );
+ self::assertSame(
+ '2',
+ $view->toolbarMetrics()[1]['value']['value'] ?? null,
+ 'Misses must count both lookups.',
+ );
+ }
+
+ public function testLoggingPreservesTheApplicationRecordAndIgnoresUnrelatedEvents(): void
+ {
+ $logger = new class extends \Psr\Log\AbstractLogger {
+ /**
+ * @var list}>
+ */
+ public array $records = [];
+ public function log($level, string|\Stringable $message, array $context = []): void
+ {
+ $this->records[] = [$level, $message, $context];
+ }
+ };
+
+ $collector = new CacheCollector((new CachePanel())->id(), $logger);
+
+ $context = [
+ 'exception' => new \RuntimeException('original'),
+ 'nested' => new \stdClass(),
+ ];
+
+ $collector->error('Original {exception}', $context);
+
+ self::assertNull(
+ $collector->capture(),
+ 'No capture exists before startup.',
+ );
+ $collector->startup();
+
+ $collector->info('Unrelated', ['event' => 'another.event']);
+
+ self::assertSame(
+ ['schema' => 1, 'operations' => []],
+ $collector->capture(),
+ 'Unrelated events must not be buffered.',
+ );
+
+ $cache = new Cache($collector);
+
+ $cache->set('key', 'private contents');
+
+ $records = $logger->records;
+
+ self::assertCount(
+ 3,
+ $records,
+ 'Every record must reach the application logger once.',
+ );
+ self::assertSame(
+ ['error', 'Original {exception}', $context],
+ $records[0],
+ 'Forwarded records must stay byte-identical.',
+ );
+ self::assertSame(
+ ['schema' => 1, 'operations' => [['set', 'key', 'stored']]],
+ $collector->capture(),
+ 'Cache events must be buffered while active.',
+ );
+ self::assertSame(
+ ['event' => 'cache.operation', 'operation' => 'set', 'key' => 'key', 'result' => 'stored'],
+ $records[2][2],
+ 'Cached contents must never enter the log context.',
+ );
+
+ $collector->shutdown();
+
+ $cache->get('key');
+
+ self::assertCount(
+ 4,
+ $logger->records,
+ 'Forwarding must continue after shutdown.',
+ );
+
+ self::assertNull(
+ $collector->capture(),
+ 'Shutdown must clear the capture.',
+ );
+
+ $collector->startup();
+
+ self::assertSame(
+ ['schema' => 1, 'operations' => []],
+ $collector->capture(),
+ 'A restarted collector must begin empty.',
+ );
+
+ $collector->shutdown();
+ }
+
+ public function testRealOperationsAndTwoRequestLifecycles(): void
+ {
+ $panel = new CachePanel();
+
+ self::assertSame(
+ 'db',
+ $panel->icon(),
+ 'The example must own its icon identifier.',
+ );
+
+ $collector = new CacheCollector($panel->id(), new \Psr\Log\NullLogger());
+ $cache = new Cache($collector);
+
+ self::assertNull(
+ $collector->capture(),
+ 'No capture exists before startup.',
+ );
+ self::assertSame(
+ $panel->id(),
+ $collector->id(),
+ 'Collector and panel must share one identifier.',
+ );
+
+ $collector->startup();
+ $collector->startup();
+
+ self::assertNull(
+ $cache->get('example'),
+ 'An unknown key must miss.',
+ );
+
+ $cache->set('example', 'private contents');
+
+ self::assertSame(
+ 'private contents',
+ $cache->get('example'),
+ 'A stored key must hit.',
+ );
+
+ $capture = $collector->capture();
+
+ self::assertSame(
+ [
+ 'schema' => 1,
+ 'operations' => [
+ ['get', 'example', 'miss'],
+ ['set', 'example', 'stored'],
+ ['get', 'example', 'hit'],
+ ],
+ ],
+ $capture,
+ 'Repeated startup must not discard buffered operations.',
+ );
+
+ $collector->shutdown();
+ $collector->shutdown();
+
+ $cache->set('other', 'not captured');
+
+ self::assertNull(
+ $collector->capture(),
+ 'Repeated shutdown must stay idempotent.',
+ );
+ self::assertSame(
+ PanelView::create()
+ ->summary(' hits', 1)
+ ->summary(' misses', 1)
+ ->toolbar('Hits', 1)
+ ->toolbar('Misses', 1)
+ ->overview(['Hits' => 1, 'Misses' => 1])
+ ->table(
+ [
+ 'Operation',
+ 'Key',
+ 'Result',
+ ],
+ [
+ ['get', 'example', 'miss'],
+ ['set', 'example', 'stored'],
+ ['get', 'example', 'hit'],
+ ],
+ collapsible: true,
+ )
+ ->jsonSerialize(),
+ $panel->present($capture)->jsonSerialize(),
+ 'A stored capture must describe the whole panel.',
+ );
+ self::assertSame(
+ '1',
+ $panel->present($capture)->toolbarMetrics()[0]['value']['value'] ?? null,
+ 'Hits must reach the toolbar as text.',
+ );
+ self::assertStringNotContainsString(
+ 'private contents',
+ json_encode($capture, JSON_THROW_ON_ERROR),
+ 'Cached contents must never be persisted.',
+ );
+
+ $collector->startup();
+
+ self::assertSame(
+ ['schema' => 1, 'operations' => []],
+ $collector->capture(),
+ 'An observed empty cache is still a capture.',
+ );
+ self::assertTrue(
+ $panel->present(['schema' => 1, 'operations' => []])->isActive(),
+ 'An empty capture must still open the panel.',
+ );
+
+ $collector->shutdown();
+ }
+
+ public function testThrowInvalidArgumentExceptionForMalformedLoggingContext(): void
+ {
+ $collector = new CacheCollector((new CachePanel())->id(), new \Psr\Log\NullLogger());
+
+ $collector->startup();
+
+ $collector->debug('Malformed', ['event' => 'cache.operation', 'key' => new \stdClass()]);
+
+ $payload = $collector->capture();
+
+ self::assertNotNull(
+ $payload,
+ 'Logging a malformed context must not throw.',
+ );
+
+ $collector->shutdown();
+
+ $this->expectException(InvalidArgumentException::class);
+
+ (new CachePanel())->present($payload);
+ }
+
+ public function testThrowInvalidArgumentExceptionForMissingCaptureSchema(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ (new CachePanel())->present([]);
+ }
+
+ /**
+ * @param array $payload
+ */
+ #[DataProviderExternal(CacheCaptureProvider::class, 'invalidCaptures')]
+ public function testThrowInvalidArgumentExceptionForStoredCapture(array $payload): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ (new CachePanel())->present($payload);
+ }
+
+ public function testThrowInvalidArgumentExceptionForUnsupportedCacheOperation(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ 'Invalid cache operation.',
+ );
+
+ (new CachePanel())->present(['schema' => 1, 'operations' => [['delete', 'key', 'hit']]]);
+ }
+
+ public function testTwoIndependentPanelsForwardEachRecordOnlyOnce(): void
+ {
+ $logger = new class extends \Psr\Log\AbstractLogger {
+ public int $calls = 0;
+
+ public function log($level, string|\Stringable $message, array $context = []): void
+ {
+ ++$this->calls;
+ }
+ };
+
+ $first = new CacheCollector('first', $logger);
+ $second = new CacheCollector('second', $first);
+ $cache = new Cache($second);
+
+ $first->startup();
+ $second->startup();
+
+ $cache->get('key');
+ $cache->set('key', 'private');
+ $cache->get('key');
+
+ self::assertSame(
+ 3,
+ $logger->calls,
+ 'Chained decorators must forward each record once.',
+ );
+ self::assertSame(
+ $first->capture(),
+ $second->capture(),
+ 'Both collectors must observe the same operations.',
+ );
+ self::assertNotSame(
+ $first->id(),
+ $second->id(),
+ 'Chained collectors must keep distinct identifiers.',
+ );
+
+ $first->shutdown();
+ $second->shutdown();
+
+ self::assertNull(
+ $first->capture(),
+ 'Shutdown must clear the outer collector.',
+ );
+ self::assertNull(
+ $second->capture(),
+ 'Shutdown must clear the inner collector.',
+ );
+ }
+}
diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php
deleted file mode 100644
index 10ec55a..0000000
--- a/tests/ExampleTest.php
+++ /dev/null
@@ -1,25 +0,0 @@
-getExample(),
- "Method should return 'false' by default.",
- );
- self::assertTrue(
- $example->getExample(true),
- "Method should return 'true' when legacy parameter is 'true'.",
- );
- }
-}
diff --git a/tests/FluentPanelViewTest.php b/tests/FluentPanelViewTest.php
new file mode 100644
index 0000000..c712c0d
--- /dev/null
+++ b/tests/FluentPanelViewTest.php
@@ -0,0 +1,204 @@
+heading('Nested')
+ ->paragraph('Nested text');
+
+ $view = PanelView::create()
+ ->summary(' hits', 3)
+ ->toolbar('Hits', 3)
+ ->active(false)
+ ->overview(['Driver' => 'redis', 'State' => $badge, 'Null' => null, 'Ratio' => 1.5], compact: true)
+ ->heading('Entries', section: true)
+ ->table(
+ ['Key', 'Value'],
+ [
+ ['home', true],
+ [$badge, PanelView::value(1)],
+ ],
+ collapsible: true,
+ styles: [0 => ColumnStyle::MONOSPACE],
+ )
+ ->callout(Tone::WARNING, 'State: ', $badge, 0)
+ ->emptyState('Empty', 'Plain', ['Mixed ', PanelView::code('x')])
+ ->disclosure('Raw', '')
+ ->group('Nested group', $nested);
+
+ self::assertSame(
+ [
+ [
+ 'kind' => 'overview',
+ 'fields' => [
+ ['label' => 'Driver', 'value' => PanelView::text('redis')],
+ ['label' => 'State', 'value' => $badge],
+ ['label' => 'Null', 'value' => PanelView::text('null')],
+ ['label' => 'Ratio', 'value' => PanelView::text('1.5')],
+ ],
+ 'compact' => true,
+ ],
+ [
+ 'kind' => 'heading',
+ 'title' => 'Entries',
+ 'section' => true,
+ ],
+ [
+ 'kind' => 'table',
+ 'headers' => ['Key', 'Value'],
+ 'rows' => [
+ [PanelView::text('home'), PanelView::text('true')],
+ [$badge, PanelView::value(1)],
+ ],
+ 'styles' => [0 => ColumnStyle::MONOSPACE],
+ 'collapsible' => true,
+ ],
+ [
+ 'kind' => 'paragraph',
+ 'content' => [PanelView::text('State: '), $badge, PanelView::text('0')],
+ 'tone' => Tone::WARNING,
+ ],
+ [
+ 'kind' => 'emptyState',
+ 'title' => 'Empty',
+ 'paragraphs' => [
+ ['kind' => 'paragraph', 'content' => [PanelView::text('Plain')], 'tone' => null],
+ [
+ 'kind' => 'paragraph',
+ 'content' => [PanelView::text('Mixed '), PanelView::code('x')],
+ 'tone' => null,
+ ],
+ ],
+ ],
+ [
+ 'kind' => 'disclosure',
+ 'title' => 'Raw',
+ 'content' => '',
+ ],
+ [
+ 'kind' => 'group',
+ 'label' => 'Nested group',
+ 'content' => $nested,
+ ],
+ ],
+ $view->blocks(),
+ 'Content, options, and ordering must survive fluent composition.',
+ );
+ self::assertFalse(
+ $view->isActive(),
+ 'Navigation visibility must remain explicit.',
+ );
+ }
+
+ public function testDefinitionNeverMutatesAnEarlierView(): void
+ {
+ $base = PanelView::create();
+ $nested = PanelView::create()
+ ->summary(' ignored', 9)
+ ->toolbar('Ignored', 8)
+ ->active(false);
+
+ $view = $base
+ ->summary(' hits', 3)
+ ->toolbar('Hits', 3)
+ ->group('Nested group', $nested);
+
+ self::assertSame(
+ [],
+ $base->blocks(),
+ 'A reusable base view must keep no content.',
+ );
+ self::assertSame(
+ [],
+ $base->summaryMetrics(),
+ 'A reusable base view must keep no metrics.',
+ );
+ self::assertTrue(
+ $base->isActive(),
+ 'A reusable base view must keep its own activity flag.',
+ );
+ self::assertCount(
+ 1,
+ $view->summaryMetrics(),
+ 'Nested metrics must not reach the root summary.',
+ );
+ self::assertCount(
+ 1,
+ $view->toolbarMetrics(),
+ 'Nested metrics must not reach the root toolbar.',
+ );
+ self::assertTrue(
+ $view->isActive(),
+ 'Nested activity must not override the root flag.',
+ );
+ }
+
+ public function testNumericOverviewKeysBecomeExplicitLabels(): void
+ {
+ $view = PanelView::create()->overview([0 => 'zero']);
+
+ self::assertSame(
+ [
+ [
+ 'kind' => 'overview',
+ 'fields' => [['label' => '0', 'value' => PanelView::text('zero')]],
+ 'compact' => false,
+ ],
+ ],
+ $view->blocks(),
+ 'Numeric labels must be converted, not dropped.',
+ );
+ }
+
+ public function testVariadicContentKeepsOrderAndSupportsUnpacking(): void
+ {
+ $badge = PanelView::badge('Ready', Tone::INFO);
+
+ $parts = ['First ', $badge, ' last'];
+
+ $view = PanelView::create()
+ ->paragraph(...$parts)
+ ->paragraph()
+ ->emptyState('Empty', 'One', 'Two');
+
+ self::assertSame(
+ [
+ [
+ 'kind' => 'paragraph',
+ 'content' => [PanelView::text('First '), $badge, PanelView::text(' last')],
+ 'tone' => null,
+ ],
+ ['kind' => 'paragraph', 'content' => [], 'tone' => null],
+ [
+ 'kind' => 'emptyState',
+ 'title' => 'Empty',
+ 'paragraphs' => [
+ ['kind' => 'paragraph', 'content' => [PanelView::text('One')], 'tone' => null],
+ ['kind' => 'paragraph', 'content' => [PanelView::text('Two')], 'tone' => null],
+ ],
+ ],
+ ],
+ $view->blocks(),
+ 'Unpacked and empty argument lists must both be accepted.',
+ );
+ self::assertSame(
+ [['kind' => 'paragraph', 'content' => [PanelView::text('A'), PanelView::text('B')], 'tone' => null]],
+ PanelView::create()->paragraph(first: 'A', second: 'B')->blocks(),
+ 'Named arguments must not leak keys into the content list.',
+ );
+ }
+}
diff --git a/tests/PanelDefinitionTest.php b/tests/PanelDefinitionTest.php
new file mode 100644
index 0000000..55c0834
--- /dev/null
+++ b/tests/PanelDefinitionTest.php
@@ -0,0 +1,81 @@
+heading('Cache entries')
+ ->table(['Key', 'Hits'], [['home', 5]]);
+ }
+ };
+
+ self::assertSame(
+ 'cache',
+ $panel->id(),
+ 'The extension must own its stable ID.',
+ );
+ self::assertSame(
+ 'Application cache',
+ $panel->name(),
+ 'The extension must own its navigation title.',
+ );
+ self::assertSame(
+ 'database',
+ $panel->icon(),
+ 'The extension must choose its icon.',
+ );
+ self::assertEquals(
+ PanelView::create()
+ ->heading('Cache entries')
+ ->table(['Key', 'Hits'], [['home', 5]]),
+ $panel->present([]),
+ 'The extension must own section titles, table headers, and values.',
+ );
+ }
+
+ public function testRejectsMissingMetadataExplicitly(): void
+ {
+ $panel = new class extends Panel {
+ public function present(array $data): PanelView
+ {
+ return PanelView::create();
+ }
+ };
+
+ $accessors = ['ICON' => $panel->icon(...), 'ID' => $panel->id(...), 'TITLE' => $panel->name(...)];
+
+ foreach ($accessors as $field => $accessor) {
+ try {
+ $accessor();
+ self::fail(
+ 'Missing definition metadata must be rejected.',
+ );
+ } catch (InvalidArgumentException $exception) {
+ self::assertSame(
+ "A panel definition must declare {$field}.",
+ $exception->getMessage(),
+ 'The error must identify the missing metadata.',
+ );
+ }
+ }
+ }
+}
diff --git a/tests/PanelViewTest.php b/tests/PanelViewTest.php
new file mode 100644
index 0000000..4d71a89
--- /dev/null
+++ b/tests/PanelViewTest.php
@@ -0,0 +1,268 @@
+heading('Title')
+ ->overview([])
+ ->table([], []);
+
+ self::assertTrue(
+ PanelView::create()->isActive(),
+ 'A described panel must be active by default.',
+ );
+ self::assertSame(
+ [
+ ['kind' => 'heading', 'title' => 'Title', 'section' => false],
+ ['kind' => 'overview', 'fields' => [], 'compact' => false],
+ ['kind' => 'table', 'headers' => [], 'rows' => [], 'styles' => [], 'collapsible' => false],
+ ],
+ $view->blocks(),
+ 'Every presentation hint must stay opt-in.',
+ );
+ }
+
+ public function testInlineFactoriesDescribeContentStyleAndTone(): void
+ {
+ self::assertSame(
+ ['kind' => 'text', 'value' => '', 'style' => 'plain'],
+ PanelView::text(''),
+ 'Text must stay unescaped and unstyled.',
+ );
+ self::assertSame(
+ ['kind' => 'text', 'value' => 'v', 'style' => 'strong'],
+ PanelView::strong('v'),
+ 'Emphasis must remain semantic.',
+ );
+ self::assertSame(
+ ['kind' => 'text', 'value' => 'v', 'style' => 'code'],
+ PanelView::code('v'),
+ 'Source code must remain semantic.',
+ );
+ self::assertSame(
+ ['kind' => 'text', 'value' => 'v', 'style' => 'preview'],
+ PanelView::preview('v'),
+ 'Clamping must be requested explicitly.',
+ );
+ self::assertSame(
+ ['kind' => 'badge', 'label' => 'shared', 'tone' => Tone::INFO],
+ PanelView::badge('shared', Tone::INFO),
+ 'Badges must retain their text and tone.',
+ );
+ self::assertSame(
+ ['kind' => 'badge', 'label' => 'b', 'tone' => Tone::MUTED],
+ PanelView::badge('b'),
+ 'Badges must default to a muted tone.',
+ );
+ self::assertSame(
+ ['kind' => 'value', 'value' => ['id' => 1], 'typeOnly' => false],
+ PanelView::value(['id' => 1]),
+ 'Diagnostic values must not be flattened.',
+ );
+ self::assertSame(
+ ['kind' => 'value', 'value' => null, 'typeOnly' => true],
+ PanelView::value(null, typeOnly: true),
+ 'Type-only presentation must be explicit.',
+ );
+ }
+
+ public function testMetricsAndFieldsShareOneLabelAndValueShape(): void
+ {
+ $view = PanelView::create()
+ ->summary(' prop', 1)
+ ->summary('', 2.5, emphasized: false)
+ ->toolbar('Hits', 3)
+ ->overview(['Driver' => 'redis']);
+
+ self::assertSame(
+ [
+ ['label' => ' prop', 'value' => ['kind' => 'text', 'value' => '1', 'style' => 'strong']],
+ ['label' => '', 'value' => ['kind' => 'text', 'value' => '2.5', 'style' => 'plain']],
+ ],
+ $view->summaryMetrics(),
+ 'Emphasis must travel as the inline text style.',
+ );
+ self::assertSame(
+ [['label' => 'Hits', 'value' => ['kind' => 'text', 'value' => '3', 'style' => 'plain']]],
+ $view->toolbarMetrics(),
+ 'Toolbar metrics must stay separate from the summary.',
+ );
+ self::assertSame(
+ [
+ [
+ 'kind' => 'overview',
+ 'fields' => [
+ ['label' => 'Driver', 'value' => ['kind' => 'text', 'value' => 'redis', 'style' => 'plain']],
+ ],
+ 'compact' => false,
+ ],
+ ],
+ $view->blocks(),
+ 'Overview fields must reuse the metric shape.',
+ );
+ }
+
+ /**
+ * @param array{kind: 'text', value: string, style: 'plain'} $expected
+ */
+ #[DataProviderExternal(InlineScalarProvider::class, 'plainText')]
+ public function testScalarContentBecomesPlainInlineText(mixed $value, array $expected): void
+ {
+ self::assertSame(
+ [['kind' => 'paragraph', 'content' => [$expected], 'tone' => null]],
+ PanelView::create()->paragraph($value)->blocks(),
+ 'Conversion must keep the literal and the plain style.',
+ );
+ self::assertSame(
+ [
+ [
+ 'kind' => 'overview',
+ 'fields' => [['label' => 'Field', 'value' => $expected]],
+ 'compact' => false,
+ ],
+ ],
+ PanelView::create()->overview(['Field' => $value])->blocks(),
+ 'Overview fields must convert identically.',
+ );
+ }
+
+ public function testStringKeyedSpreadContentStaysAList(): void
+ {
+ $parts = ['first' => 'A', 'second' => PanelView::code('B')];
+
+ $content = [PanelView::text('A'), PanelView::code('B')];
+
+ self::assertSame(
+ [['kind' => 'paragraph', 'content' => $content, 'tone' => null]],
+ PanelView::create()->paragraph(...$parts)->blocks(),
+ 'String keys must not reach the paragraph content.',
+ );
+ self::assertSame(
+ [['kind' => 'paragraph', 'content' => $content, 'tone' => Tone::WARNING]],
+ PanelView::create()->callout(Tone::WARNING, ...$parts)->blocks(),
+ 'String keys must not reach the callout content.',
+ );
+ }
+
+ public function testTableKeepsEveryStyledColumnUnderItsIndex(): void
+ {
+ $view = PanelView::create()
+ ->table(
+ ['Key', 'Value', 'Hits'],
+ [['home', 'cached', 3]],
+ styles: [0 => ColumnStyle::MONOSPACE, 2 => ColumnStyle::NUMBER],
+ );
+
+ self::assertSame(
+ [
+ [
+ 'kind' => 'table',
+ 'headers' => ['Key', 'Value', 'Hits'],
+ 'rows' => [[PanelView::text('home'), PanelView::text('cached'), PanelView::text('3')]],
+ 'styles' => [0 => ColumnStyle::MONOSPACE, 2 => ColumnStyle::NUMBER],
+ 'collapsible' => false,
+ ],
+ ],
+ $view->blocks(),
+ 'Every styled column must survive, not only the first.',
+ );
+ }
+
+ public function testThrowInvalidArgumentExceptionForAssociativeParagraph(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ 'Debug panel paragraphs must be scalars, inline values, or lists of them.',
+ );
+
+ PanelView::create()->emptyState('Empty', ['first' => 'A']);
+ }
+
+ public function testThrowInvalidArgumentExceptionForForgedInlineValue(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ 'Debug panel inline content must be a scalar, null, or a PanelView inline value. Got array.',
+ );
+
+ PanelView::create()->paragraph(['kind' => 'text']);
+ }
+
+ public function testThrowInvalidArgumentExceptionForNonColumnStyle(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ 'Debug panel column styles must be ' . ColumnStyle::class . ' cases.',
+ );
+
+ PanelView::create()->table(['One'], [['a']], styles: [0 => 'pill']);
+ }
+
+ public function testThrowInvalidArgumentExceptionForNonInlineObject(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ 'Debug panel inline content must be a scalar, null, or a PanelView inline value. Got stdClass.',
+ );
+
+ PanelView::create()->paragraph(new stdClass());
+ }
+
+ public function testThrowInvalidArgumentExceptionForNonListRow(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ 'Debug panel table rows must be lists whose width matches the headers.',
+ );
+
+ PanelView::create()->table(['One'], ['not a row']);
+ }
+
+ public function testThrowInvalidArgumentExceptionForNonStringHeader(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ 'Debug panel table headers must be plain strings.',
+ );
+
+ PanelView::create()->table([1], [[1]]);
+ }
+
+ public function testThrowInvalidArgumentExceptionForRowWidthMismatch(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ 'Debug panel table rows must be lists whose width matches the headers.',
+ );
+
+ PanelView::create()->table(['One'], [[]]);
+ }
+
+ public function testThrowInvalidArgumentExceptionForUnknownStyledColumn(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ 'Debug panel column styles must be keyed by an existing column index.',
+ );
+
+ PanelView::create()->table(['One'], [['a']], styles: [1 => ColumnStyle::PILL]);
+ }
+}
diff --git a/tests/Provider/CacheCaptureProvider.php b/tests/Provider/CacheCaptureProvider.php
new file mode 100644
index 0000000..5de5bc0
--- /dev/null
+++ b/tests/Provider/CacheCaptureProvider.php
@@ -0,0 +1,34 @@
+}>
+ */
+ public static function invalidCaptures(): iterable
+ {
+ yield 'boolean operation' => [['schema' => 1, 'operations' => [[true, 'key', 'hit']]]];
+ yield 'boolean result' => [['schema' => 1, 'operations' => [['get', 'key', true]]]];
+ yield 'extra cell' => [['schema' => 1, 'operations' => [['get', 'key', 'hit', 'extra']]]];
+ yield 'get cannot store' => [['schema' => 1, 'operations' => [['get', 'key', 'stored']]]];
+ yield 'missing result' => [['schema' => 1, 'operations' => [['get', 'key']]]];
+ yield 'non-array operations' => [['schema' => 1, 'operations' => 'invalid']];
+ yield 'non-array row' => [['schema' => 1, 'operations' => ['invalid']]];
+ yield 'non-list operations' => [['schema' => 1, 'operations' => [1 => ['get', 'key', 'hit']]]];
+ yield 'null key' => [['schema' => 1, 'operations' => [['get', null, 'hit']]]];
+ yield 'numeric key' => [['schema' => 1, 'operations' => [['get', 1, 'hit']]]];
+ yield 'numeric set result' => [['schema' => 1, 'operations' => [['set', 'key', 1]]]];
+ yield 'set cannot hit' => [['schema' => 1, 'operations' => [['set', 'key', 'hit']]]];
+ yield 'string schema' => [['schema' => '1', 'operations' => []]];
+ yield 'unsupported schema' => [['schema' => 2, 'operations' => []]];
+ }
+}
diff --git a/tests/Provider/InlineScalarProvider.php b/tests/Provider/InlineScalarProvider.php
new file mode 100644
index 0000000..366f114
--- /dev/null
+++ b/tests/Provider/InlineScalarProvider.php
@@ -0,0 +1,26 @@
+
+ */
+ public static function plainText(): iterable
+ {
+ yield 'false' => [false, ['kind' => 'text', 'value' => 'false', 'style' => 'plain']];
+ yield 'float' => [1.5, ['kind' => 'text', 'value' => '1.5', 'style' => 'plain']];
+ yield 'int' => [7, ['kind' => 'text', 'value' => '7', 'style' => 'plain']];
+ yield 'null' => [null, ['kind' => 'text', 'value' => 'null', 'style' => 'plain']];
+ yield 'string' => ['', ['kind' => 'text', 'value' => '', 'style' => 'plain']];
+ yield 'true' => [true, ['kind' => 'text', 'value' => 'true', 'style' => 'plain']];
+ }
+}
diff --git a/tests/Support/Cache.php b/tests/Support/Cache.php
new file mode 100644
index 0000000..679c318
--- /dev/null
+++ b/tests/Support/Cache.php
@@ -0,0 +1,54 @@
+
+ */
+ private array $values = [];
+
+ public function __construct(private readonly LoggerInterface $logger) {}
+
+ public function get(string $key): mixed
+ {
+ $hit = array_key_exists($key, $this->values);
+
+ $this->logger->debug(
+ 'Cache {operation}: {result}',
+ [
+ 'event' => 'cache.operation',
+ 'operation' => 'get',
+ 'key' => $key,
+ 'result' => $hit ? 'hit' : 'miss',
+ ],
+ );
+
+ return $this->values[$key] ?? null;
+ }
+
+ public function set(string $key, mixed $value): void
+ {
+ $this->values[$key] = $value;
+
+ $this->logger->debug(
+ 'Cache {operation}: {result}',
+ [
+ 'event' => 'cache.operation',
+ 'operation' => 'set',
+ 'key' => $key,
+ 'result' => 'stored',
+ ],
+ );
+ }
+}
diff --git a/tests/Support/CacheCollector.php b/tests/Support/CacheCollector.php
new file mode 100644
index 0000000..7cfceb0
--- /dev/null
+++ b/tests/Support/CacheCollector.php
@@ -0,0 +1,64 @@
+
+ */
+ private array $operations = [];
+ private bool $started = false;
+
+ public function __construct(private readonly string $panelId, private readonly LoggerInterface $logger) {}
+
+ /**
+ * @return array{schema: int, operations: list}|null
+ */
+ public function capture(): array|null
+ {
+ return $this->started ? ['schema' => 1, 'operations' => $this->operations] : null;
+ }
+
+ public function id(): string
+ {
+ return $this->panelId;
+ }
+
+ /**
+ * @param mixed $level
+ * @param array $context
+ */
+ public function log($level, string|Stringable $message, array $context = []): void
+ {
+ $this->logger->log($level, $message, $context);
+
+ if ($this->started && ($context['event'] ?? null) === 'cache.operation') {
+ $this->operations[] = [
+ $context['operation'] ?? null,
+ $context['key'] ?? null,
+ $context['result'] ?? null];
+ }
+ }
+
+ public function shutdown(): void
+ {
+ $this->started = false;
+
+ $this->operations = [];
+ }
+
+ public function startup(): void
+ {
+ $this->started = true;
+ }
+}
diff --git a/tests/Support/CachePanel.php b/tests/Support/CachePanel.php
new file mode 100644
index 0000000..bd947c5
--- /dev/null
+++ b/tests/Support/CachePanel.php
@@ -0,0 +1,94 @@
+> Results each observed operation is allowed to report.
+ */
+ private const array RESULTS = ['get' => ['hit', 'miss'], 'set' => ['stored']];
+
+ public function present(array $data): PanelView
+ {
+ if (
+ ($data['schema'] ?? null) !== 1 || !is_array($data['operations'] ?? null)
+ || !array_is_list($data['operations'])
+ ) {
+ throw new InvalidArgumentException(
+ 'Invalid cache capture: expected schema 1 and an operations list.',
+ );
+ }
+
+ $rows = [];
+
+ foreach ($data['operations'] as $row) {
+ $rows[] = self::operation($row);
+ }
+
+ $results = array_column($rows, 2);
+ $hits = count(array_keys($results, 'hit', true));
+ $misses = count(array_keys($results, 'miss', true));
+
+ $view = PanelView::create()
+ ->summary(' hits', $hits)
+ ->summary(' misses', $misses)
+ ->toolbar('Hits', $hits)
+ ->toolbar('Misses', $misses)
+ ->overview(['Hits' => $hits, 'Misses' => $misses]);
+
+ return $rows === []
+ ? $view->emptyState('No cache operations', 'The cache was observed, but no operations occurred.')
+ : $view->table(['Operation', 'Key', 'Result'], $rows, collapsible: true);
+ }
+
+ /**
+ * Validates one stored operation, rejecting results the cache could not have produced.
+ *
+ * @param mixed $row Stored operation.
+ *
+ * @throws InvalidArgumentException If the row is not an operation, key, and result the cache can report.
+ *
+ * @return array{string, string, string} Operation, key, and result.
+ */
+ private static function operation(mixed $row): array
+ {
+ if (is_array($row) === false || array_keys($row) !== [0, 1, 2]) {
+ throw new InvalidArgumentException('Invalid cache operation.');
+ }
+
+ $operation = $row[0] ?? null;
+ $key = $row[1] ?? null;
+ $result = $row[2] ?? null;
+
+ if (
+ is_string($operation) === false
+ || is_string($key) === false
+ || is_string($result) === false
+ || in_array($result, self::RESULTS[$operation] ?? [], true) === false
+ ) {
+ throw new InvalidArgumentException(
+ 'Invalid cache operation.',
+ );
+ }
+
+ return [$operation, $key, $result];
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
deleted file mode 100644
index e69de29..0000000