diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index effd916..f39234e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ permissions: jobs: phpunit: - uses: yii2-framework/actions/.github/workflows/phpunit.yml@a28eb7d4ab03abd53ccd7c79014b3b27d09932ff # v2.0.1 + uses: php-forge/actions/.github/workflows/phpunit.yml@a574dbe95fc370db1b7b5ea546cd5ff5b70304d6 # v3.0.0 with: php-version: '["8.3","8.4","8.5"]' secrets: diff --git a/.github/workflows/dependency-check.yml b/.github/workflows/dependency-check.yml index c938637..543f666 100644 --- a/.github/workflows/dependency-check.yml +++ b/.github/workflows/dependency-check.yml @@ -17,4 +17,4 @@ permissions: jobs: composer-require-checker: - uses: yii2-framework/actions/.github/workflows/composer-require-checker.yml@a28eb7d4ab03abd53ccd7c79014b3b27d09932ff # v2.0.1 + uses: php-forge/actions/.github/workflows/composer-require-checker.yml@a574dbe95fc370db1b7b5ea546cd5ff5b70304d6 # v3.0.0 diff --git a/.github/workflows/ecs.yml b/.github/workflows/ecs.yml index e4159b4..a446051 100644 --- a/.github/workflows/ecs.yml +++ b/.github/workflows/ecs.yml @@ -17,4 +17,4 @@ permissions: jobs: easy-coding-standard: - uses: yii2-framework/actions/.github/workflows/ecs.yml@a28eb7d4ab03abd53ccd7c79014b3b27d09932ff # v2.0.1 + uses: php-forge/actions/.github/workflows/ecs.yml@a574dbe95fc370db1b7b5ea546cd5ff5b70304d6 # v3.0.0 diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 0828f10..ee275be 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -17,7 +17,7 @@ permissions: jobs: mutation: - uses: yii2-framework/actions/.github/workflows/infection.yml@a28eb7d4ab03abd53ccd7c79014b3b27d09932ff # v2.0.1 + uses: php-forge/actions/.github/workflows/infection.yml@a574dbe95fc370db1b7b5ea546cd5ff5b70304d6 # v3.0.0 with: phpstan: true secrets: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 613b0ca..765cdfc 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -10,13 +10,10 @@ permissions: jobs: quality: - uses: yii2-framework/actions/.github/workflows/quality.yml@a28eb7d4ab03abd53ccd7c79014b3b27d09932ff # v2.0.1 + uses: php-forge/actions/.github/workflows/quality.yml@a574dbe95fc370db1b7b5ea546cd5ff5b70304d6 # v3.0.0 permissions: contents: read with: codespell-config: .github/linters/.codespellrc editorconfig-checker-config: .github/linters/.editorconfig-checker.json markdownlint-config: .github/linters/.markdown-lint.yml - prettier-config: .prettierrc.json - prettier-ignore-path: .prettierignore - yamllint-targets: .github diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6a771ba..b3b632c 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -10,7 +10,7 @@ permissions: jobs: security: - uses: yii2-framework/actions/.github/workflows/security.yml@a28eb7d4ab03abd53ccd7c79014b3b27d09932ff # v2.0.1 + uses: php-forge/actions/.github/workflows/security.yml@a574dbe95fc370db1b7b5ea546cd5ff5b70304d6 # v3.0.0 permissions: contents: read with: diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 108f5a6..97bd4cf 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -17,4 +17,4 @@ permissions: jobs: phpstan: - uses: yii2-framework/actions/.github/workflows/phpstan.yml@a28eb7d4ab03abd53ccd7c79014b3b27d09932ff # v2.0.1 + uses: php-forge/actions/.github/workflows/phpstan.yml@a574dbe95fc370db1b7b5ea546cd5ff5b70304d6 # v3.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8253512..7fd7616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,5 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. ## 0.1.0 Under development + +- feat: initial development release. diff --git a/README.md b/README.md index c461dc7..89e49a4 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,187 @@

- - - - Yii Framework - -

Template

+ + PHP Forge + +

Debug Interop


- - PHPUnit + + PHPUnit - - Mutation Testing + + Mutation Testing - - PHPStan + + PHPStan - - Security + + Security

- 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. - - - Feature Overview - +## 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 [![PHP](https://img.shields.io/badge/%3E%3D8.3-777BB4.svg?style=for-the-badge&logo=php&logoColor=white)](https://www.php.net/releases/8.3/en.php) -[![Yii 22.0.x](https://img.shields.io/badge/22.0.x-0073AA.svg?style=for-the-badge&logo=yii&logoColor=white)](https://github.com/yiisoft/yii2/tree/22.0) -[![Latest Stable Version](https://img.shields.io/packagist/v/yii2-extensions/template.svg?style=for-the-badge&logo=packagist&logoColor=white&label=Stable)](https://packagist.org/packages/yii2-extensions/template) -[![Total Downloads](https://img.shields.io/packagist/dt/yii2-extensions/template.svg?style=for-the-badge&logo=composer&logoColor=white&label=Downloads)](https://packagist.org/packages/yii2-extensions/template) +[![PHPStan Level Max](https://img.shields.io/badge/PHPStan-Level%20Max-4F5D95.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/php-forge/debug/actions/workflows/static.yml) +[![Latest Stable Version](https://img.shields.io/packagist/v/php-forge/debug.svg?style=for-the-badge&logo=packagist&logoColor=white&label=Stable)](https://packagist.org/packages/php-forge/debug) +[![Total Downloads](https://img.shields.io/packagist/dt/php-forge/debug.svg?style=for-the-badge&logo=composer&logoColor=white&label=Downloads)](https://packagist.org/packages/php-forge/debug) -## Project status +## Code quality -[![Codecov](https://img.shields.io/codecov/c/github/yii2-extensions/template.svg?style=for-the-badge&logo=codecov&logoColor=white&label=Coverage)](https://codecov.io/github/yii2-extensions/template) -[![PHPStan Level Max](https://img.shields.io/badge/PHPStan-Level%20Max-4F5D95.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/yii2-extensions/template/actions/workflows/static.yml) -[![Quality](https://img.shields.io/github/actions/workflow/status/yii2-extensions/template/quality.yml?style=for-the-badge&label=Quality&logo=github)](https://github.com/yii2-extensions/template/actions/workflows/quality.yml) -[![StyleCI](https://img.shields.io/badge/StyleCI-Passed-44CC11.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.styleci.io/repos/698621511?branch=main) +[![Codecov](https://img.shields.io/codecov/c/github/php-forge/debug.svg?style=for-the-badge&logo=codecov&logoColor=white&label=Coverage)](https://codecov.io/gh/php-forge/debug) +[![Quality](https://img.shields.io/github/actions/workflow/status/php-forge/debug/quality.yml?style=for-the-badge&label=Quality&logo=github)](https://github.com/php-forge/debug/actions/workflows/quality.yml) +[![StyleCI](https://img.shields.io/badge/StyleCI-Passed-44CC11.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.styleci.io/repos/php-forge/debug?branch=main) -## Our social networks +## Social networks [![Follow on X](https://img.shields.io/badge/-Follow%20on%20X-1DA1F2.svg?style=for-the-badge&logo=x&logoColor=white&labelColor=000000)](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 @@ - - - - - - - - Best Practices - PSR standards • Clean code • Documentation ready - PER 3.0 + PSR-12 coding standards - - - - - - Easy Setup - Quick start template • Configuration examples • Clear structure - Ready-to-use project scaffolding - - - - - - GitHub Actions - CI/CD workflows • Automated testing • Quality checks - Super-Linter • PHPStan • ECS • Infection - - - - - - Quality Tools - PHPStan Level Max • StyleCI • Super-Linter integration - Mutation testing with 100% MSI requirement - - - - - - PHP 8.3+ - Modern PHP features • Type safety • Performance optimizations - Multi-version CI matrix: 8.3, 8.4, 8.5 - - - - - - Testing Ready - PHPUnit 11.5 • Mutation testing • Codecov integration - Dependency checker • Rector automated refactoring - - 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 @@ - - - - - - - Best Practices - PSR standards • Clean code • Documentation ready - PER 3.0 + PSR-12 coding standards - - - - - - Easy Setup - Quick start template • Configuration examples • Clear structure - Ready-to-use project scaffolding - - - - - - GitHub Actions - CI/CD workflows • Automated testing • Quality checks - Super-Linter • PHPStan • ECS • Infection - - - - - - Quality Tools - PHPStan Level Max • StyleCI • Super-Linter integration - Mutation testing with 100% MSI requirement - - - - - - PHP 8.3+ - Modern PHP features • Type safety • Performance optimizations - Multi-version CI matrix: 8.3, 8.4, 8.5 - - - - - - Testing Ready - PHPUnit 11.5 • Mutation testing • Codecov integration - Dependency checker • Rector automated refactoring - - 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