diff --git a/CHANGELOG.md b/CHANGELOG.md index 607d858..d47d403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 0.1.4 Under development +## 0.2.0 Under development + +- feat(panel-view)!: add typed presenters and factory methods; remove array shapes, JSON serialization, and runtime validation. ## 0.1.3 September 14, 2026 diff --git a/README.md b/README.md index 3b0fb95..bedd607 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,9 @@ 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. +Five types are published for panel authors: `CollectorInterface`, `Panel`, `PanelView`, `Tone`, and `ColumnStyle`. +Everything a panel can display is a `PanelView` method, so an extension imports no value class and builds nothing by +hand. ```php use PHPForge\Debug\{ColumnStyle, PanelView, Tone}; @@ -174,9 +175,26 @@ PanelView::create() ``` 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()`. +`::value()` produce validated inline values accepted wherever a scalar is accepted. Methods with explicit value +validation reject invalid input with an `InvalidArgumentException`; arguments with incompatible declared types raise +PHP's native `TypeError`. + +The host reads the finished description through `summaryMetrics()`, `toolbarMetrics()`, `blocks()`, and `isActive()`. +Those accessors return `PHPForge\Debug\Presenter` value objects: `SummaryMetric`, `ToolbarMetric`, and the blocks, +entries, and inline values behind them. A renderer narrows each value with `instanceof` over the sealed `Block` and +`Inline` unions, which static analysis proves exhaustive, and reads its public properties. Inline text carries a +`TextStyle` case and a table carries its `ColumnStyle` cases, so semantics reach the markup without parsing strings. + +```php +use PHPForge\Debug\Presenter\{HeadingBlock, ParagraphBlock, TableBlock}; + +$html = match (true) { + $block instanceof HeadingBlock => $this->heading($block->title, $block->section), + $block instanceof ParagraphBlock => $this->paragraph($block->content, $block->tone), + $block instanceof TableBlock => $this->table($block->headers, $block->rows, $block->styles), + // one arm per block; PHPStan reports a missing arm as an unhandled match value. +}; +``` ## Documentation diff --git a/src/Exception/PanelViewMessage.php b/src/Exception/PanelViewMessage.php index 400d818..a8cf989 100644 --- a/src/Exception/PanelViewMessage.php +++ b/src/Exception/PanelViewMessage.php @@ -27,13 +27,6 @@ enum PanelViewMessage: string */ case COLUMN_STYLE_KEY_INVALID = 'Debug panel column styles must be keyed by an existing column index.'; - /** - * Indicates that an entry passed to a composite block was not built by the matching factory. - * - * Format: "Debug panel %s entries must be built with PanelView::%s()." - */ - case ENTRY_INVALID = 'Debug panel %s entries must be built with PanelView::%s().'; - /** * Indicates that an inline value is neither a scalar, `null`, nor a shape this class produces. * diff --git a/src/PanelView.php b/src/PanelView.php index 5d49e79..8a5b51a 100644 --- a/src/PanelView.php +++ b/src/PanelView.php @@ -5,21 +5,14 @@ namespace PHPForge\Debug; use InvalidArgumentException; -use JsonSerializable; use PHPForge\Debug\Exception\PanelViewMessage; -use function array_key_exists; -use function count; -use function in_array; +use function array_is_list; +use function array_values; use function is_array; -use function is_bool; use function is_float; use function is_int; use function is_string; -use function parse_url; -use function strpbrk; -use function strtolower; -use function trim; /** * Builds immutable panel descriptions without exposing the host's markup or styles. @@ -28,57 +21,16 @@ * scalars become text, with literal text for `null`, `true`, and `false`. Styled text, badges, and structured values * come from the static factories and are accepted wherever a scalar is accepted. * - * The host reads the result through {@see self::summaryMetrics()}, {@see self::toolbarMetrics()}, {@see self::blocks()}, - * and {@see self::isActive()}. Nothing outside this class can build or alter a shape. - * - * Inline values. - * - * @phpstan-type BadgeInline array{kind: 'badge', label: string, tone: Tone} - * @phpstan-type LinkInline array{kind: 'link', label: string, href: string, external: bool} - * @phpstan-type TextInline array{kind: 'text', value: string, style: 'code'|'plain'|'preview'|'sql'|'strong'} - * @phpstan-type TraceInline array{kind: 'trace', frames: list>} - * @phpstan-type ValueInline array{kind: 'value', value: mixed, typeOnly: bool} - * @phpstan-type Inline BadgeInline|LinkInline|TextInline|TraceInline|ValueInline - * @phpstan-type Pair array{label: string, value: Inline} - * @phpstan-type TextPair array{label: string, value: TextInline} - * - * Entries of a composite block. - * - * @phpstan-type FactEntry array{kind: 'fact', label: string, value: string} - * @phpstan-type PackageEntry array{kind: 'package', name: string, version: string} - * @phpstan-type PillEntry array{kind: 'pill', label: string, state: string, enabled: bool} - * @phpstan-type ReadoutEntry array{kind: 'readout', label: string, value: string, caption: string} - * - * Content blocks. - * - * @phpstan-type DisclosureBlock array{kind: 'disclosure', title: string, content: string} - * @phpstan-type EmptyStateBlock array{kind: 'emptyState', title: string, paragraphs: list} - * @phpstan-type FactsBlock array{kind: 'facts', facts: list} - * @phpstan-type GroupBlock array{kind: 'group', label: string, content: PanelView} - * @phpstan-type HeadingBlock array{kind: 'heading', title: string, section: bool} - * @phpstan-type ManifestBlock array{kind: 'manifest', label: string, packages: list} - * @phpstan-type OverviewBlock array{kind: 'overview', fields: list, compact: bool} - * @phpstan-type ParagraphBlock array{kind: 'paragraph', content: list, tone: Tone|null} - * @phpstan-type PillsBlock array{kind: 'pills', pills: list} - * @phpstan-type ReadoutsBlock array{kind: 'readouts', readouts: list} - * @phpstan-type SectionBlock array{kind: 'section', mark: string, title: string, count: int|null, content: PanelView} - * @phpstan-type TableBlock array{ - * kind: 'table', - * headers: list, - * rows: list>, - * styles: array, - * collapsible: bool, - * filterable: bool - * } - * @phpstan-type Block DisclosureBlock|EmptyStateBlock|FactsBlock|GroupBlock|HeadingBlock|ManifestBlock|OverviewBlock - * |ParagraphBlock|PillsBlock|ReadoutsBlock|SectionBlock|TableBlock + * The finished description is a tree of `PHPForge\Debug\Presenter` value objects. The host reads it through + * {@see self::summaryMetrics()}, {@see self::toolbarMetrics()}, {@see self::blocks()}, and {@see self::isActive()}, + * then narrows each value with `instanceof` over the sealed {@see Block} and {@see Inline} unions. */ -final readonly class PanelView implements JsonSerializable +final readonly class PanelView { /** - * @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 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( @@ -106,21 +58,17 @@ public function active(bool $active): self * @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. + * @return Presenter\BadgeInline Inline badge accepted by every content method. */ - public static function badge(string $label, Tone $tone = Tone::MUTED): array + public static function badge(string $label, Tone $tone = Tone::MUTED): Presenter\BadgeInline { - return [ - 'kind' => 'badge', - 'label' => $label, - 'tone' => $tone, - ]; + return new Presenter\BadgeInline($label, $tone); } /** * Returns the content blocks for the host renderer. * - * @return list Validated content blocks in display order. + * @return list Validated content blocks in display order. */ public function blocks(): array { @@ -142,20 +90,69 @@ public function callout(Tone $tone, mixed ...$content): self return $this->append(self::paragraphBlock($content, $tone)); } + /** + * Appends a card describing one entity, optionally split into titled columns of its own content. + * + * @param string $id Anchor the host emits so other blocks can link to the card, or `''` to omit it. + * @param string $icon Host icon key shown before the title, or `''` to omit it. + * @param string $title Entity name shown as the card heading; the host escapes it. + * @param string $subtitle Qualifier shown under the title, or `''` to omit it. + * @param array $meta Inline values or scalars shown beside the title, such as counts. + * @param Presenter\ColumnEntry ...$columns Entries produced by {@see self::column()}. + * + * @throws InvalidArgumentException if a meta value is not an accepted inline value. + * + * @return self New view with the card appended. + */ + public function card( + string $id, + string $icon, + string $title, + string $subtitle, + array $meta, + Presenter\ColumnEntry ...$columns + ): self { + $inline = []; + + foreach ($meta as $value) { + $inline[] = self::inline($value); + } + + return $this->append( + new Presenter\CardBlock( + $id, + $icon, + $title, + $subtitle, + $inline, + array_values($columns), + ), + ); + } + /** * 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. + * @return Presenter\TextInline Inline text accepted by every content method. + */ + public static function code(string $value): Presenter\TextInline + { + return new Presenter\TextInline($value, Presenter\TextStyle::CODE); + } + + /** + * Creates one titled column of a card body. + * + * @param string $title Column heading announced as its accessible name. + * @param self $content Child view contributing only its ordered content blocks. + * + * @return Presenter\ColumnEntry Column entry accepted by {@see self::card()}. */ - public static function code(string $value): array + public static function column(string $title, self $content): Presenter\ColumnEntry { - return [ - 'kind' => 'text', - 'value' => $value, - 'style' => 'code', - ]; + return new Presenter\ColumnEntry($title, $content); } /** @@ -178,7 +175,7 @@ public static function create(): self */ public function disclosure(string $title, string $content): self { - return $this->append(['kind' => 'disclosure', 'title' => $title, 'content' => $content]); + return $this->append(new Presenter\DisclosureBlock($title, $content)); } /** @@ -200,7 +197,7 @@ public function emptyState(string $title, mixed ...$paragraphs): self $content[] = self::paragraphOf($paragraph); } - return $this->append(['kind' => 'emptyState', 'title' => $title, 'paragraphs' => $content]); + return $this->append(new Presenter\EmptyStateBlock($title, $content)); } /** @@ -209,37 +206,49 @@ public function emptyState(string $title, mixed ...$paragraphs): self * @param string $label Name of the fact; the host escapes it. * @param string $value Recorded value; the host escapes it. * - * @return FactEntry Fact entry accepted by {@see self::facts()}. + * @return Presenter\FactEntry Fact entry accepted by {@see self::facts()}. */ - public static function fact(string $label, string $value): array + public static function fact(string $label, string $value): Presenter\FactEntry { - return [ - 'kind' => 'fact', - 'label' => $label, - 'value' => $value, - ]; + return new Presenter\FactEntry($label, $value); } /** * Appends a compact strip of label and value pairs. * - * @param array ...$facts Entries produced by {@see self::fact()}. - * - * @throws InvalidArgumentException if an argument was not built by {@see self::fact()}. + * @param Presenter\FactEntry ...$facts Entries produced by {@see self::fact()}. * * @return self New view with the fact strip appended. */ - public function facts(array ...$facts): self + public function facts(Presenter\FactEntry ...$facts): self { - $entries = []; - - foreach ($facts as $fact) { - self::assertFactEntry($fact); + return $this->append(new Presenter\FactsBlock(array_values($facts))); + } - $entries[] = $fact; - } + /** + * Creates one typed file entry of a file list. + * + * @param string $type Short kind label shown as a pill, such as `.css`; the host escapes it. + * @param string $name File name or URL; the host escapes it. + * @param Tone $tone Semantic tone interpreted by the host frontend. + * + * @return Presenter\FileEntry File entry accepted by {@see self::files()}. + */ + public static function file(string $type, string $name, Tone $tone = Tone::MUTED): Presenter\FileEntry + { + return new Presenter\FileEntry($type, $name, $tone); + } - return $this->append(['kind' => 'facts', 'facts' => $entries]); + /** + * Appends a list of typed file names. + * + * @param Presenter\FileEntry ...$files Entries produced by {@see self::file()}. + * + * @return self New view with the file list appended. + */ + public function files(Presenter\FileEntry ...$files): self + { + return $this->append(new Presenter\FilesBlock(array_values($files))); } /** @@ -252,7 +261,7 @@ public function facts(array ...$facts): self */ public function group(string $label, self $content): self { - return $this->append(['kind' => 'group', 'label' => $label, 'content' => $content]); + return $this->append(new Presenter\GroupBlock($label, $content)); } /** @@ -265,7 +274,7 @@ public function group(string $label, self $content): self */ public function heading(string $title, bool $section = false): self { - return $this->append(['kind' => 'heading', 'title' => $title, 'section' => $section]); + return $this->append(new Presenter\HeadingBlock($title, $section)); } /** @@ -278,21 +287,6 @@ 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, - ]; - } - /** * Creates an inline navigation link the host renders as an anchor. * @@ -305,39 +299,37 @@ public function jsonSerialize(): array * * @throws InvalidArgumentException if the target declares a scheme the host must not follow. * - * @return LinkInline Inline link accepted by every content method. + * @return Presenter\LinkInline Inline link accepted by every content method. */ - public static function link(string $label, string $href, bool $external = false): array + public static function link(string $label, string $href, bool $external = false): Presenter\LinkInline { - return [ - 'kind' => 'link', - 'label' => $label, - 'href' => self::target($href), - 'external' => $external, - ]; + return new Presenter\LinkInline($label, $href, $external); + } + + /** + * Appends a labeled strip of navigation links. + * + * @param string $label Text introducing the strip, such as `Depends on 2`; the host escapes it. + * @param Presenter\LinkInline ...$links Inline values produced by {@see self::link()}. + * + * @return self New view with the link strip appended. + */ + public function links(string $label, Presenter\LinkInline ...$links): self + { + return $this->append(new Presenter\LinksBlock($label, array_values($links))); } /** * Appends a vendor-grouped package manifest. * * @param string $label Vendor prefix the packages share, shown as the group heading. - * @param array ...$packages Entries produced by {@see self::package()}. - * - * @throws InvalidArgumentException if an argument was not built by {@see self::package()}. + * @param Presenter\PackageEntry ...$packages Entries produced by {@see self::package()}. * * @return self New view with the manifest appended. */ - public function manifest(string $label, array ...$packages): self + public function manifest(string $label, Presenter\PackageEntry ...$packages): self { - $entries = []; - - foreach ($packages as $package) { - self::assertPackageEntry($package); - - $entries[] = $package; - } - - return $this->append(['kind' => 'manifest', 'label' => $label, 'packages' => $entries]); + return $this->append(new Presenter\ManifestBlock($label, array_values($packages))); } /** @@ -357,10 +349,10 @@ public function overview(array $values, bool $compact = false): self $fields = []; foreach ($values as $label => $value) { - $fields[] = ['label' => (string) $label, 'value' => self::inline($value)]; + $fields[] = new Presenter\FieldEntry((string) $label, self::inline($value)); } - return $this->append(['kind' => 'overview', 'fields' => $fields, 'compact' => $compact]); + return $this->append(new Presenter\OverviewBlock($fields, $compact)); } /** @@ -369,15 +361,11 @@ public function overview(array $values, bool $compact = false): self * @param string $name Package name; the host escapes it. * @param string $version Resolved version; the host escapes it. * - * @return PackageEntry Package entry accepted by {@see self::manifest()}. + * @return Presenter\PackageEntry Package entry accepted by {@see self::manifest()}. */ - public static function package(string $name, string $version): array + public static function package(string $name, string $version): Presenter\PackageEntry { - return [ - 'kind' => 'package', - 'name' => $name, - 'version' => $version, - ]; + return new Presenter\PackageEntry($name, $version); } /** @@ -401,38 +389,23 @@ public function paragraph(mixed ...$content): self * @param string $state Short state text shown after the label, such as `on` or a version. * @param bool $enabled Whether the subject is active, selecting the on or off presentation. * - * @return PillEntry Pill entry accepted by {@see self::pills()}. + * @return Presenter\PillEntry Pill entry accepted by {@see self::pills()}. */ - public static function pill(string $label, string $state, bool $enabled): array + public static function pill(string $label, string $state, bool $enabled): Presenter\PillEntry { - return [ - 'kind' => 'pill', - 'label' => $label, - 'state' => $state, - 'enabled' => $enabled, - ]; + return new Presenter\PillEntry($label, $state, $enabled); } /** * Appends a strip of status pills. * - * @param array ...$pills Entries produced by {@see self::pill()}. - * - * @throws InvalidArgumentException if an argument was not built by {@see self::pill()}. + * @param Presenter\PillEntry ...$pills Entries produced by {@see self::pill()}. * * @return self New view with the pill strip appended. */ - public function pills(array ...$pills): self + public function pills(Presenter\PillEntry ...$pills): self { - $entries = []; - - foreach ($pills as $pill) { - self::assertPillEntry($pill); - - $entries[] = $pill; - } - - return $this->append(['kind' => 'pills', 'pills' => $entries]); + return $this->append(new Presenter\PillsBlock(array_values($pills))); } /** @@ -440,15 +413,11 @@ public function pills(array ...$pills): self * * @param string $value Text content; the host escapes it. * - * @return TextInline Inline text accepted by every content method. + * @return Presenter\TextInline Inline text accepted by every content method. */ - public static function preview(string $value): array + public static function preview(string $value): Presenter\TextInline { - return [ - 'kind' => 'text', - 'value' => $value, - 'style' => 'preview', - ]; + return new Presenter\TextInline($value, Presenter\TextStyle::PREVIEW); } /** @@ -458,38 +427,23 @@ public static function preview(string $value): array * @param string $value Headline value; the host escapes it. * @param string $caption Qualifier shown under the value, or `''` to omit it. * - * @return ReadoutEntry Readout entry accepted by {@see self::readouts()}. + * @return Presenter\ReadoutEntry Readout entry accepted by {@see self::readouts()}. */ - public static function readout(string $label, string $value, string $caption = ''): array + public static function readout(string $label, string $value, string $caption = ''): Presenter\ReadoutEntry { - return [ - 'kind' => 'readout', - 'label' => $label, - 'value' => $value, - 'caption' => $caption, - ]; + return new Presenter\ReadoutEntry($label, $value, $caption); } /** * Appends a row of headline readout cards. * - * @param array ...$readouts Entries produced by {@see self::readout()}. - * - * @throws InvalidArgumentException if an argument was not built by {@see self::readout()}. + * @param Presenter\ReadoutEntry ...$readouts Entries produced by {@see self::readout()}. * * @return self New view with the readout row appended. */ - public function readouts(array ...$readouts): self + public function readouts(Presenter\ReadoutEntry ...$readouts): self { - $entries = []; - - foreach ($readouts as $readout) { - self::assertReadoutEntry($readout); - - $entries[] = $readout; - } - - return $this->append(['kind' => 'readouts', 'readouts' => $entries]); + return $this->append(new Presenter\ReadoutsBlock(array_values($readouts))); } /** @@ -504,15 +458,7 @@ public function readouts(array ...$readouts): self */ public function section(string $mark, string $title, self $content, int|null $count = null): self { - return $this->append( - [ - 'kind' => 'section', - 'mark' => $mark, - 'title' => $title, - 'count' => $count, - 'content' => $content, - ], - ); + return $this->append(new Presenter\SectionBlock($mark, $title, $count, $content)); } /** @@ -520,15 +466,38 @@ public function section(string $mark, string $title, self $content, int|null $co * * @param string $value Statement text; the host escapes it. * - * @return TextInline Inline text accepted by every content method. + * @return Presenter\TextInline Inline text accepted by every content method. */ - public static function sql(string $value): array + public static function sql(string $value): Presenter\TextInline { - return [ - 'kind' => 'text', - 'value' => $value, - 'style' => 'sql', - ]; + return new Presenter\TextInline($value, Presenter\TextStyle::SQL); + } + + /** + * Creates one headline stat tile. + * + * @param string $icon Host icon key shown above the value. + * @param string $label Metric name shown under the value; the host escapes it. + * @param string $value Headline value; the host escapes it. + * @param Tone $tone Semantic tone interpreted by the host frontend, or {@see Tone::MUTED} to keep its own accent. + * + * @return Presenter\StatEntry Stat entry accepted by {@see self::stats()}. + */ + public static function stat(string $icon, string $label, string $value, Tone $tone = Tone::MUTED): Presenter\StatEntry + { + return new Presenter\StatEntry($icon, $label, $value, $tone); + } + + /** + * Appends a strip of headline stat tiles. + * + * @param Presenter\StatEntry ...$stats Entries produced by {@see self::stat()}. + * + * @return self New view with the stat strip appended. + */ + public function stats(Presenter\StatEntry ...$stats): self + { + return $this->append(new Presenter\StatsBlock(array_values($stats))); } /** @@ -536,15 +505,11 @@ public static function sql(string $value): array * * @param string $value Text content; the host escapes it. * - * @return TextInline Inline text accepted by every content method. + * @return Presenter\TextInline Inline text accepted by every content method. */ - public static function strong(string $value): array + public static function strong(string $value): Presenter\TextInline { - return [ - 'kind' => 'text', - 'value' => $value, - 'style' => 'strong', - ]; + return new Presenter\TextInline($value, Presenter\TextStyle::STRONG); } /** @@ -558,10 +523,10 @@ public static function strong(string $value): array */ 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), - ]; + $metric = new Presenter\SummaryMetric( + $label, + $emphasized ? self::strong((string) $value) : self::text((string) $value), + ); return new self([...$this->summary, $metric], $this->blocks, $this->toolbar, $this->active); } @@ -569,7 +534,7 @@ public function summary(string $label, string|int|float $value, bool $emphasized /** * Returns the summary metrics for the host renderer. * - * @return list Validated summary metrics in display order. + * @return list Validated summary metrics in display order. */ public function summaryMetrics(): array { @@ -596,35 +561,27 @@ public function table( array $styles = [], bool $filterable = false, ): 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, - 'filterable' => $filterable, - ], + new Presenter\TableBlock( + $headers, + self::cells($rows), + $styles, + $collapsible, + $filterable, + ), ); } - /** * Creates plain inline text. * * @param string $value Text content; the host escapes it. * - * @return TextInline Inline text accepted by every content method. + * @return Presenter\TextInline Inline text accepted by every content method. */ - public static function text(string $value): array + public static function text(string $value): Presenter\TextInline { - return [ - 'kind' => 'text', - 'value' => $value, - 'style' => 'plain', - ]; + return new Presenter\TextInline($value, Presenter\TextStyle::PLAIN); } /** @@ -637,10 +594,7 @@ public static function text(string $value): array */ public function toolbar(string $label, string|int|float $value): self { - $metric = [ - 'label' => $label, - 'value' => self::text((string) $value), - ]; + $metric = new Presenter\ToolbarMetric($label, (string) $value); return new self($this->summary, $this->blocks, [...$this->toolbar, $metric], $this->active); } @@ -648,7 +602,7 @@ public function toolbar(string $label, string|int|float $value): self /** * Returns the toolbar metrics for the host renderer. * - * @return list Validated toolbar metrics in display order. + * @return list Validated toolbar metrics in display order. */ public function toolbarMetrics(): array { @@ -664,32 +618,11 @@ public function toolbarMetrics(): array * * @throws InvalidArgumentException if a frame is not an array of fields. * - * @return TraceInline Inline trace accepted by every content method. + * @return Presenter\TraceInline Inline trace accepted by every content method. */ - public static function trace(array $frames): array + public static function trace(array $frames): Presenter\TraceInline { - $captured = []; - - foreach ($frames as $frame) { - if (is_array($frame) === false) { - throw new InvalidArgumentException( - PanelViewMessage::TRACE_FRAME_INVALID->getMessage(), - ); - } - - $fields = []; - - foreach ($frame as $key => $value) { - $fields[(string) $key] = $value; - } - - $captured[] = $fields; - } - - return [ - 'kind' => 'trace', - 'frames' => $captured, - ]; + return new Presenter\TraceInline($frames); } /** @@ -698,143 +631,55 @@ public static function trace(array $frames): array * @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. + * @return Presenter\ValueInline Inline value accepted by every content method. */ - public static function value(mixed $value, bool $typeOnly = false): array + public static function value(mixed $value, bool $typeOnly = false): Presenter\ValueInline { - return [ - 'kind' => 'value', - 'value' => $value, - 'typeOnly' => $typeOnly, - ]; + return new Presenter\ValueInline($value, $typeOnly); } /** * Appends a content block while retaining metrics and activity. * - * @param Block $block Content block placed after the existing blocks. + * @param Presenter\Block $block Content block placed after the existing blocks. * * @return self New view containing the additional block. */ - private function append(array $block): self + private function append(Presenter\Block $block): self { return new self($this->summary, [...$this->blocks, $block], $this->toolbar, $this->active); } /** - * Asserts that an entry was built by {@see self::fact()}. - * - * @param array $entry Entry passed to {@see self::facts()}. - * - * @throws InvalidArgumentException if the entry does not carry the shape the factory produces. - * - * @phpstan-assert FactEntry $entry - */ - private static function assertFactEntry(array $entry): void - { - if ( - ($entry['kind'] ?? null) !== 'fact' - || is_string($entry['label'] ?? null) === false - || is_string($entry['value'] ?? null) === false - ) { - throw new InvalidArgumentException( - PanelViewMessage::ENTRY_INVALID->getMessage('fact', 'fact'), - ); - } - } - - /** - * Asserts that an entry was built by {@see self::package()}. - * - * @param array $entry Entry passed to {@see self::manifest()}. - * - * @throws InvalidArgumentException if the entry does not carry the shape the factory produces. - * - * @phpstan-assert PackageEntry $entry - */ - private static function assertPackageEntry(array $entry): void - { - if ( - ($entry['kind'] ?? null) !== 'package' - || is_string($entry['name'] ?? null) === false - || is_string($entry['version'] ?? null) === false - ) { - throw new InvalidArgumentException( - PanelViewMessage::ENTRY_INVALID->getMessage('package', 'package'), - ); - } - } - - /** - * Asserts that an entry was built by {@see self::pill()}. + * Converts table rows to inline cells, rejecting rows that are not lists. * - * @param array $entry Entry passed to {@see self::pills()}. + * @param array $rows Rows to normalize. * - * @throws InvalidArgumentException if the entry does not carry the shape the factory produces. + * @throws InvalidArgumentException if a row is not a list or a cell is not an accepted inline value. * - * @phpstan-assert PillEntry $entry + * @return list> Normalized rows in display order. */ - private static function assertPillEntry(array $entry): void + private static function cells(array $rows): array { - if ( - ($entry['kind'] ?? null) !== 'pill' - || is_string($entry['label'] ?? null) === false - || is_string($entry['state'] ?? null) === false - || is_bool($entry['enabled'] ?? null) === false - ) { - throw new InvalidArgumentException( - PanelViewMessage::ENTRY_INVALID->getMessage('pill', 'pill'), - ); - } - } - - /** - * Asserts that an entry was built by {@see self::readout()}. - * - * @param array $entry Entry passed to {@see self::readouts()}. - * - * @throws InvalidArgumentException if the entry does not carry the shape the factory produces. - * - * @phpstan-assert ReadoutEntry $entry - */ - private static function assertReadoutEntry(array $entry): void - { - if ( - ($entry['kind'] ?? null) !== 'readout' - || is_string($entry['label'] ?? null) === false - || is_string($entry['value'] ?? null) === false - || is_string($entry['caption'] ?? null) === false - ) { - throw new InvalidArgumentException( - PanelViewMessage::ENTRY_INVALID->getMessage('readout', 'readout'), - ); - } - } - - /** - * 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 = []; + $result = []; - foreach ($headers as $header) { - if (is_string($header) === false) { + foreach ($rows as $row) { + if (is_array($row) === false || array_is_list($row) === false) { throw new InvalidArgumentException( - PanelViewMessage::TABLE_HEADER_INVALID->getMessage(), + PanelViewMessage::TABLE_ROW_WIDTH_INVALID->getMessage(), ); } - $columns[] = $header; + $cells = []; + + foreach ($row as $value) { + $cells[] = self::inline($value); + } + + $result[] = $cells; } - return $columns; + return $result; } /** @@ -844,12 +689,12 @@ private static function headers(array $headers): array * * @throws InvalidArgumentException if the value is not an accepted inline input. * - * @return Inline Normalized inline value. + * @return Presenter\Inline Normalized inline value. */ - private static function inline(mixed $value): array + private static function inline(mixed $value): Presenter\Inline { return match (true) { - is_array($value) => self::inlineShape($value), + $value instanceof Presenter\Inline => $value, $value === null => self::text('null'), $value === true => self::text('true'), $value === false => self::text('false'), @@ -859,71 +704,6 @@ private static function inline(mixed $value): array }; } - /** - * 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]; - } - - $href = $value['href'] ?? null; - $external = $value['external'] ?? null; - - if ($kind === 'link' && is_string($label) && is_string($href) && is_bool($external)) { - return [ - 'kind' => 'link', - 'label' => $label, - 'href' => self::target($href), - 'external' => $external, - ]; - } - - $text = $value['value'] ?? null; - $style = $value['style'] ?? null; - - if ( - $kind === 'text' - && is_string($text) - && in_array($style, ['code', 'plain', 'preview', 'sql', 'strong'], true) - ) { - return [ - 'kind' => 'text', - 'value' => $text, - 'style' => $style, - ]; - } - - $frames = $value['frames'] ?? null; - - if ($kind === 'trace' && is_array($frames)) { - return self::trace($frames); - } - - $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. * @@ -932,9 +712,9 @@ private static function inlineShape(array $value): array * * @throws InvalidArgumentException if an item is not an accepted inline value. * - * @return ParagraphBlock Validated paragraph block. + * @return Presenter\ParagraphBlock Normalized paragraph block. */ - private static function paragraphBlock(array $content, Tone|null $tone): array + private static function paragraphBlock(array $content, Tone|null $tone): Presenter\ParagraphBlock { $inline = []; @@ -942,25 +722,21 @@ private static function paragraphBlock(array $content, Tone|null $tone): array $inline[] = self::inline($value); } - return [ - 'kind' => 'paragraph', - 'content' => $inline, - 'tone' => $tone, - ]; + return new Presenter\ParagraphBlock($inline, $tone); } /** - * Builds one paragraph from a string, a single inline value, or a list of inline values. + * Builds one paragraph from a scalar, 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. + * @return Presenter\ParagraphBlock Normalized paragraph block. */ - private static function paragraphOf(mixed $paragraph): array + private static function paragraphOf(mixed $paragraph): Presenter\ParagraphBlock { - if (is_array($paragraph) === false || array_key_exists('kind', $paragraph)) { + if (is_array($paragraph) === false) { return self::paragraphBlock([$paragraph], null); } @@ -973,112 +749,6 @@ private static function paragraphOf(mixed $paragraph): array 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( - PanelViewMessage::TABLE_ROW_WIDTH_INVALID->getMessage(), - ); - } - - $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( - PanelViewMessage::COLUMN_STYLE_KEY_INVALID->getMessage(), - ); - } - - if ($style instanceof ColumnStyle === false) { - throw new InvalidArgumentException( - PanelViewMessage::COLUMN_STYLE_INVALID->getMessage(ColumnStyle::class), - ); - } - - $result[$column] = $style; - } - - return $result; - } - - /** - * Rejects a link target the host must not follow. - * - * Characters a browser strips while resolving a URL are rejected first, because they move the scheme: a tab, a line - * break, or a surrounding space turns `" javascript:alert(1)"` into an executable target after the check. - * - * @param string $href Candidate target. - * - * @throws InvalidArgumentException if the target carries characters a browser strips, cannot be parsed, or declares - * a scheme other than `http`, `https`, or `mailto`. - * - * @return string Unmodified target. - */ - private static function target(string $href): string - { - if (strpbrk($href, "\t\n\r") !== false || trim($href, "\x00..\x20") !== $href) { - throw new InvalidArgumentException( - PanelViewMessage::LINK_TARGET_NORMALIZED->getMessage(), - ); - } - - $parts = parse_url($href); - - if ($parts === false) { - throw new InvalidArgumentException( - PanelViewMessage::LINK_TARGET_UNPARSABLE->getMessage(), - ); - } - - $scheme = $parts['scheme'] ?? null; - - if ($scheme !== null && in_array(strtolower($scheme), ['http', 'https', 'mailto'], true) === false) { - throw new InvalidArgumentException( - PanelViewMessage::LINK_TARGET_SCHEME_INVALID->getMessage($scheme), - ); - } - - return $href; - } - /** * Builds the rejection naming the value that cannot be displayed inline. * diff --git a/src/Presenter/BadgeInline.php b/src/Presenter/BadgeInline.php new file mode 100644 index 0000000..2dfaf8a --- /dev/null +++ b/src/Presenter/BadgeInline.php @@ -0,0 +1,19 @@ + $meta Inline values shown beside the title, such as counts. + * @param list $columns Titled columns of the card body in display order. + */ + public function __construct( + public string $id, + public string $icon, + public string $title, + public string $subtitle, + public array $meta, + public array $columns, + ) {} +} diff --git a/src/Presenter/ColumnEntry.php b/src/Presenter/ColumnEntry.php new file mode 100644 index 0000000..e24e81e --- /dev/null +++ b/src/Presenter/ColumnEntry.php @@ -0,0 +1,19 @@ + $paragraphs Ordered explanations, each one paragraph. + */ + public function __construct(public string $title, public array $paragraphs) {} +} diff --git a/src/Presenter/FactEntry.php b/src/Presenter/FactEntry.php new file mode 100644 index 0000000..b3cde65 --- /dev/null +++ b/src/Presenter/FactEntry.php @@ -0,0 +1,17 @@ + $facts Fact pairs in display order. + */ + public function __construct(public array $facts) {} +} diff --git a/src/Presenter/FieldEntry.php b/src/Presenter/FieldEntry.php new file mode 100644 index 0000000..423043e --- /dev/null +++ b/src/Presenter/FieldEntry.php @@ -0,0 +1,17 @@ + $files File entries in display order. + */ + public function __construct(public array $files) {} +} diff --git a/src/Presenter/GroupBlock.php b/src/Presenter/GroupBlock.php new file mode 100644 index 0000000..8e511bc --- /dev/null +++ b/src/Presenter/GroupBlock.php @@ -0,0 +1,19 @@ +getMessage(), + ); + } + + $parts = parse_url($href); + + if ($parts === false) { + throw new InvalidArgumentException( + PanelViewMessage::LINK_TARGET_UNPARSABLE->getMessage(), + ); + } + + $scheme = $parts['scheme'] ?? null; + + if ($scheme !== null && in_array(strtolower($scheme), ['http', 'https', 'mailto'], true) === false) { + throw new InvalidArgumentException( + PanelViewMessage::LINK_TARGET_SCHEME_INVALID->getMessage($scheme), + ); + } + } +} diff --git a/src/Presenter/LinksBlock.php b/src/Presenter/LinksBlock.php new file mode 100644 index 0000000..5fec4d5 --- /dev/null +++ b/src/Presenter/LinksBlock.php @@ -0,0 +1,17 @@ + $links Links in display order. + */ + public function __construct(public string $label, public array $links) {} +} diff --git a/src/Presenter/ManifestBlock.php b/src/Presenter/ManifestBlock.php new file mode 100644 index 0000000..bdbcc77 --- /dev/null +++ b/src/Presenter/ManifestBlock.php @@ -0,0 +1,17 @@ + $packages Packages in display order. + */ + public function __construct(public string $label, public array $packages) {} +} diff --git a/src/Presenter/OverviewBlock.php b/src/Presenter/OverviewBlock.php new file mode 100644 index 0000000..70e42c0 --- /dev/null +++ b/src/Presenter/OverviewBlock.php @@ -0,0 +1,17 @@ + $fields Labeled fields in display order. + * @param bool $compact Whether to request compact presentation from the host. + */ + public function __construct(public array $fields, public bool $compact) {} +} diff --git a/src/Presenter/PackageEntry.php b/src/Presenter/PackageEntry.php new file mode 100644 index 0000000..68cccf4 --- /dev/null +++ b/src/Presenter/PackageEntry.php @@ -0,0 +1,17 @@ + $content Inline values in display order. + * @param Tone|null $tone Callout tone interpreted by the host frontend, or `null` for an ordinary paragraph. + */ + public function __construct(public array $content, public Tone|null $tone) {} +} diff --git a/src/Presenter/PillEntry.php b/src/Presenter/PillEntry.php new file mode 100644 index 0000000..294495f --- /dev/null +++ b/src/Presenter/PillEntry.php @@ -0,0 +1,18 @@ + $pills Pills in display order. + */ + public function __construct(public array $pills) {} +} diff --git a/src/Presenter/ReadoutEntry.php b/src/Presenter/ReadoutEntry.php new file mode 100644 index 0000000..955915b --- /dev/null +++ b/src/Presenter/ReadoutEntry.php @@ -0,0 +1,18 @@ + $readouts Readout cards in display order. + */ + public function __construct(public array $readouts) {} +} diff --git a/src/Presenter/SectionBlock.php b/src/Presenter/SectionBlock.php new file mode 100644 index 0000000..2cb8c50 --- /dev/null +++ b/src/Presenter/SectionBlock.php @@ -0,0 +1,26 @@ + $stats Stat tiles in display order. + */ + public function __construct(public array $stats) {} +} diff --git a/src/Presenter/SummaryMetric.php b/src/Presenter/SummaryMetric.php new file mode 100644 index 0000000..44af318 --- /dev/null +++ b/src/Presenter/SummaryMetric.php @@ -0,0 +1,17 @@ + Plain-text column headings in display order. + */ + public array $headers; + + /** + * @var array Semantic column styles keyed by column index. + */ + public array $styles; + + /** + * @param array $headers Plain-text column headings in display order. + * @param list> $rows Rows of inline values in display order. + * @param array $styles {@see ColumnStyle} cases keyed by column index. + * @param bool $collapsible Whether the host may collapse the table. + * @param bool $filterable Whether to request the host's in-place row filter for the table. + * + * @throws InvalidArgumentException if a heading, a row width, or a column style is invalid. + */ + public function __construct( + array $headers, + public array $rows, + array $styles, + public bool $collapsible, + public bool $filterable, + ) { + $columns = []; + + foreach ($headers as $header) { + if (is_string($header) === false) { + throw new InvalidArgumentException( + PanelViewMessage::TABLE_HEADER_INVALID->getMessage(), + ); + } + + $columns[] = $header; + } + + $width = count($columns); + + foreach ($rows as $row) { + if (count($row) !== $width) { + throw new InvalidArgumentException( + PanelViewMessage::TABLE_ROW_WIDTH_INVALID->getMessage(), + ); + } + } + + $declared = []; + + foreach ($styles as $column => $style) { + if (is_int($column) === false || $column < 0 || $column >= $width) { + throw new InvalidArgumentException( + PanelViewMessage::COLUMN_STYLE_KEY_INVALID->getMessage(), + ); + } + + if ($style instanceof ColumnStyle === false) { + throw new InvalidArgumentException( + PanelViewMessage::COLUMN_STYLE_INVALID->getMessage(ColumnStyle::class), + ); + } + + $declared[$column] = $style; + } + + $this->headers = $columns; + $this->styles = $declared; + } +} diff --git a/src/Presenter/TextInline.php b/src/Presenter/TextInline.php new file mode 100644 index 0000000..85fddc7 --- /dev/null +++ b/src/Presenter/TextInline.php @@ -0,0 +1,17 @@ +> Captured frames in call order, each an array of frame fields. + */ + public array $frames; + + /** + * @param array $frames Captured frames in call order, each an array of frame fields. + * + * @throws InvalidArgumentException if a frame is not an array of fields. + */ + public function __construct(array $frames) + { + $captured = []; + + foreach ($frames as $frame) { + if (is_array($frame) === false) { + throw new InvalidArgumentException( + PanelViewMessage::TRACE_FRAME_INVALID->getMessage(), + ); + } + + $fields = []; + + foreach ($frame as $key => $value) { + $fields[(string) $key] = $value; + } + + $captured[] = $fields; + } + + $this->frames = $captured; + } +} diff --git a/src/Presenter/ValueInline.php b/src/Presenter/ValueInline.php new file mode 100644 index 0000000..2d5968f --- /dev/null +++ b/src/Presenter/ValueInline.php @@ -0,0 +1,17 @@ +shutdown(); - $view = $panel->present($payload); + $metrics = $panel->present($payload)->toolbarMetrics(); self::assertSame( '3', - $view->toolbarMetrics()[0]['value']['value'] ?? null, + ($metrics[0] ?? null)?->value, 'Hits must count every reuse.', ); self::assertSame( '2', - $view->toolbarMetrics()[1]['value']['value'] ?? null, + ($metrics[1] ?? null)?->value, 'Misses must count both lookups.', ); } @@ -208,7 +208,7 @@ public function testRealOperationsAndTwoRequestLifecycles(): void $collector->capture(), 'Repeated shutdown must stay idempotent.', ); - self::assertSame( + self::assertEquals( PanelView::create() ->summary(' hits', 1) ->summary(' misses', 1) @@ -227,14 +227,16 @@ public function testRealOperationsAndTwoRequestLifecycles(): void ['get', 'example', 'hit'], ], collapsible: true, - ) - ->jsonSerialize(), - $panel->present($capture)->jsonSerialize(), + ), + $panel->present($capture), 'A stored capture must describe the whole panel.', ); + + $metrics = $panel->present($capture)->toolbarMetrics(); + self::assertSame( '1', - $panel->present($capture)->toolbarMetrics()[0]['value']['value'] ?? null, + ($metrics[0] ?? null)?->value, 'Hits must reach the toolbar as text.', ); self::assertStringNotContainsString( diff --git a/tests/CompositeBlockTest.php b/tests/CompositeBlockTest.php index f448376..a6c8b7c 100644 --- a/tests/CompositeBlockTest.php +++ b/tests/CompositeBlockTest.php @@ -4,34 +4,87 @@ namespace PHPForge\Debug\Tests; -use Closure; use InvalidArgumentException; use PHPForge\Debug\Exception\PanelViewMessage; -use PHPForge\Debug\PanelView; -use PHPForge\Debug\Tests\Provider\MalformedEntryProvider; -use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; +use PHPForge\Debug\{PanelView, Tone}; +use PHPForge\Debug\Presenter\{ + CardBlock, + ColumnEntry, + FactEntry, + FactsBlock, + FileEntry, + FilesBlock, + LinkInline, + LinksBlock, + ManifestBlock, + PackageEntry, + PillEntry, + PillsBlock, + ReadoutEntry, + ReadoutsBlock, + SectionBlock, + StatEntry, + StatsBlock, + TextInline, + TextStyle, +}; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use stdClass; /** - * Unit tests for the {@see PanelView} composite blocks describing facts, readouts, pills, manifests, and sections. - * - * {@see MalformedEntryProvider} for test case data providers. + * Unit tests for the {@see PanelView} composite blocks describing cards, stats, files, links, facts, readouts, pills, + * manifests, and sections. */ #[Group('panel-view')] final class CompositeBlockTest extends TestCase { - public function testFactsKeepTheirLabelAndValuePairs(): void + public function testCardKeepsItsMetaAndTitledColumnsInDeclarationOrder(): void { - self::assertSame( + $badge = PanelView::badge('3 css', Tone::INFO); + + $files = PanelView::create()->files(PanelView::file('.css', 'site.css', Tone::INFO)); + $wiring = PanelView::create()->facts(PanelView::fact('SOURCE', '@app/assets')); + $view = PanelView::create() + ->card( + 'app-asset', + 'asset', + 'AppAsset', + 'App\\Asset\\', + [$badge, '1 js'], + PanelView::column('FILES', $files), + PanelView::column('WIRING', $wiring), + ); + + self::assertEquals( [ - [ - 'kind' => 'facts', - 'facts' => [ - ['kind' => 'fact', 'label' => 'Charset', 'value' => 'UTF-8'], - ['kind' => 'fact', 'label' => 'Language', 'value' => 'en'], - ], - ], + new CardBlock( + 'app-asset', + 'asset', + 'AppAsset', + 'App\\Asset\\', + [$badge, new TextInline('1 js', TextStyle::PLAIN)], + [new ColumnEntry('FILES', $files), new ColumnEntry('WIRING', $wiring)], + ), ], + $view->blocks(), + 'A card keeps its meta and columns in declaration order.', + ); + } + + public function testCardWithoutMetaOrColumnsCarriesEmptyLists(): void + { + self::assertEquals( + [new CardBlock('', '', 'AppAsset', '', [], [])], + PanelView::create()->card('', '', 'AppAsset', '', [])->blocks(), + 'An unadorned card keeps its optional parts empty.', + ); + } + + public function testFactsKeepTheirLabelAndValuePairs(): void + { + self::assertEquals( + [new FactsBlock([new FactEntry('Charset', 'UTF-8'), new FactEntry('Language', 'en')])], PanelView::create() ->facts(PanelView::fact('Charset', 'UTF-8'), PanelView::fact('Language', 'en')) ->blocks(), @@ -39,6 +92,59 @@ public function testFactsKeepTheirLabelAndValuePairs(): void ); } + public function testFilesKeepTheirTypeToneAndOrder(): void + { + self::assertEquals( + new FileEntry('.js', 'app.js', Tone::MUTED), + PanelView::file('.js', 'app.js'), + 'A file without a tone stays muted.', + ); + self::assertEquals( + [ + new FilesBlock( + [ + new FileEntry('.css', 'site.css', Tone::INFO), + new FileEntry('.js', 'app.js', Tone::WARNING), + ], + ), + new FilesBlock([]), + ], + PanelView::create() + ->files( + PanelView::file('.css', 'site.css', Tone::INFO), + PanelView::file('.js', 'app.js', Tone::WARNING), + ) + ->files() + ->blocks(), + 'A file list keeps every entry in declaration order.', + ); + } + + public function testLinksKeepTheirLabelAndOrder(): void + { + self::assertEquals( + [ + new LinksBlock( + 'Depends on 2', + [ + new LinkInline('YiiAsset', '#yii-asset', false), + new LinkInline('Docs', 'https://example.test/d', true), + ], + ), + new LinksBlock('Depends on 0', []), + ], + PanelView::create() + ->links( + 'Depends on 2', + PanelView::link('YiiAsset', '#yii-asset'), + PanelView::link('Docs', 'https://example.test/d', true), + ) + ->links('Depends on 0') + ->blocks(), + 'A link strip keeps every target in declaration order.', + ); + } + public function testManifestGroupsPackagesUnderOneVendorLabel(): void { $view = PanelView::create()->manifest( @@ -47,16 +153,12 @@ public function testManifestGroupsPackagesUnderOneVendorLabel(): void PanelView::package('arrays', 'v3.2.1'), ); - self::assertSame( + self::assertEquals( [ - [ - 'kind' => 'manifest', - 'label' => 'yiisoft/', - 'packages' => [ - ['kind' => 'package', 'name' => 'aliases', 'version' => 'v3.1.1'], - ['kind' => 'package', 'name' => 'arrays', 'version' => 'v3.2.1'], - ], - ], + new ManifestBlock( + 'yiisoft/', + [new PackageEntry('aliases', 'v3.1.1'), new PackageEntry('arrays', 'v3.2.1')], + ), ], $view->blocks(), 'A manifest keeps its packages in declaration order under the vendor label.', @@ -70,16 +172,8 @@ public function testPillsKeepTheirStateAndOrder(): void PanelView::pill('Memcache', 'off', false), ); - self::assertSame( - [ - [ - 'kind' => 'pills', - 'pills' => [ - ['kind' => 'pill', 'label' => 'APCu', 'state' => 'on', 'enabled' => true], - ['kind' => 'pill', 'label' => 'Memcache', 'state' => 'off', 'enabled' => false], - ], - ], - ], + self::assertEquals( + [new PillsBlock([new PillEntry('APCu', 'on', true), new PillEntry('Memcache', 'off', false)])], $view->blocks(), 'A pill strip keeps every subject with its own state.', ); @@ -87,14 +181,16 @@ public function testPillsKeepTheirStateAndOrder(): void public function testReadoutCaptionIsOptional(): void { - self::assertSame( - ['kind' => 'readout', 'label' => 'Yii', 'value' => '3', 'caption' => ''], + self::assertEquals( + new ReadoutEntry('Yii', '3', ''), PanelView::readout('Yii', '3'), 'A readout without a qualifier carries an empty caption.', ); - self::assertSame( - [['kind' => 'readouts', 'readouts' => [['kind' => 'readout', 'label' => 'PHP', 'value' => '8.5.9', 'caption' => 'runtime']]]], - PanelView::create()->readouts(PanelView::readout('PHP', '8.5.9', 'runtime'))->blocks(), + self::assertEquals( + [new ReadoutsBlock([new ReadoutEntry('PHP', '8.5.9', 'runtime')])], + PanelView::create() + ->readouts(PanelView::readout('PHP', '8.5.9', 'runtime')) + ->blocks(), 'A readout row carries its cards in declaration order.', ); } @@ -107,29 +203,51 @@ public function testSectionCountIsOptionalAndWrapsItsOwnBlocks(): void ->section('//', 'Details', $content, 47) ->blocks(); - self::assertSame( - ['kind' => 'section', 'mark' => '::', 'title' => 'Extensions', 'count' => null, 'content' => $content], - $blocks[0] ?? [], + self::assertEquals( + new SectionBlock('::', 'Extensions', null, $content), + $blocks[0] ?? null, 'A section without a tally reports `null`.', ); - self::assertSame( - ['kind' => 'section', 'mark' => '//', 'title' => 'Details', 'count' => 47, 'content' => $content], - $blocks[1] ?? [], + self::assertEquals( + new SectionBlock('//', 'Details', 47, $content), + $blocks[1] ?? null, 'A section keeps the tally it was given.', ); } - /** - * @param Closure(): PanelView $build Composition that must reject the malformed entry. - * @param string $kind Entry kind the rejected argument was meant to carry. - */ - #[DataProviderExternal(MalformedEntryProvider::class, 'entries')] - public function testThrowInvalidArgumentExceptionForAnEntryTheFactoryDidNotBuild(Closure $build, string $kind): void + + public function testStatsKeepTheirIconValueAndTone(): void + { + $tile = PanelView::stat('asset', 'BUNDLES', '4', Tone::INFO); + + $tiles = ['bundles' => $tile]; + + self::assertEquals( + new StatEntry('link', 'LINKS', '2', Tone::MUTED), + PanelView::stat('link', 'LINKS', '2'), + 'A stat without a tone stays muted.', + ); + self::assertEquals( + [new StatsBlock([new StatEntry('asset', 'BUNDLES', '4', Tone::INFO)]), new StatsBlock([])], + PanelView::create() + ->stats($tile) + ->stats() + ->blocks(), + 'A stat strip keeps its tiles in declaration order.', + ); + self::assertEquals( + [new StatsBlock([$tile])], + PanelView::create()->stats(...$tiles)->blocks(), + 'String keys must not reach the stat list.', + ); + } + + public function testThrowInvalidArgumentExceptionForCardMetaNoInlineFactoryBuilt(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - PanelViewMessage::ENTRY_INVALID->getMessage($kind, $kind), + PanelViewMessage::INLINE_CONTENT_INVALID->getMessage('stdClass'), ); - $build(); + PanelView::create()->card('', '', 'AppAsset', '', [new stdClass()]); } } diff --git a/tests/FluentPanelViewTest.php b/tests/FluentPanelViewTest.php index 3cef310..c734c2d 100644 --- a/tests/FluentPanelViewTest.php +++ b/tests/FluentPanelViewTest.php @@ -5,6 +5,16 @@ namespace PHPForge\Debug\Tests; use PHPForge\Debug\{ColumnStyle, PanelView, Tone}; +use PHPForge\Debug\Presenter\{ + DisclosureBlock, + EmptyStateBlock, + FieldEntry, + GroupBlock, + HeadingBlock, + OverviewBlock, + ParagraphBlock, + TableBlock, +}; use PHPUnit\Framework\TestCase; /** @@ -19,7 +29,6 @@ public function testDefinitionKeepsEveryContentOptionAndOrder(): void $nested = PanelView::create() ->heading('Nested') ->paragraph('Nested text'); - $view = PanelView::create() ->summary(' hits', 3) ->toolbar('Hits', 3) @@ -40,61 +49,41 @@ public function testDefinitionKeepsEveryContentOptionAndOrder(): void ->disclosure('Raw', '') ->group('Nested group', $nested); - self::assertSame( + self::assertEquals( [ - [ - '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')], + new OverviewBlock( + [ + new FieldEntry('Driver', PanelView::text('redis')), + new FieldEntry('State', $badge), + new FieldEntry('Null', PanelView::text('null')), + new FieldEntry('Ratio', PanelView::text('1.5')), ], - 'compact' => true, - ], - [ - 'kind' => 'heading', - 'title' => 'Entries', - 'section' => true, - ], - [ - 'kind' => 'table', - 'headers' => ['Key', 'Value'], - 'rows' => [ + true, + ), + new HeadingBlock('Entries', true), + new TableBlock( + ['Key', 'Value'], + [ [PanelView::text('home'), PanelView::text('true')], [$badge, PanelView::value(1)], ], - 'styles' => [0 => ColumnStyle::MONOSPACE], - 'collapsible' => true, - 'filterable' => false, - ], - [ - '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, - ], + [0 => ColumnStyle::MONOSPACE], + true, + false, + ), + new ParagraphBlock( + [PanelView::text('State: '), $badge, PanelView::text('0')], + Tone::WARNING, + ), + new EmptyStateBlock( + 'Empty', + [ + new ParagraphBlock([PanelView::text('Plain')], null), + new ParagraphBlock([PanelView::text('Mixed '), PanelView::code('x')], null), ], - ], - [ - 'kind' => 'disclosure', - 'title' => 'Raw', - 'content' => '', - ], - [ - 'kind' => 'group', - 'label' => 'Nested group', - 'content' => $nested, - ], + ), + new DisclosureBlock('Raw', ''), + new GroupBlock('Nested group', $nested), ], $view->blocks(), 'Content, options, and ordering must survive fluent composition.', @@ -152,14 +141,8 @@ public function testNumericOverviewKeysBecomeExplicitLabels(): void { $view = PanelView::create()->overview([0 => 'zero']); - self::assertSame( - [ - [ - 'kind' => 'overview', - 'fields' => [['label' => '0', 'value' => PanelView::text('zero')]], - 'compact' => false, - ], - ], + self::assertEquals( + [new OverviewBlock([new FieldEntry('0', PanelView::text('zero'))], false)], $view->blocks(), 'Numeric labels must be converted, not dropped.', ); @@ -176,28 +159,23 @@ public function testVariadicContentKeepsOrderAndSupportsUnpacking(): void ->paragraph() ->emptyState('Empty', 'One', 'Two'); - self::assertSame( + self::assertEquals( [ - [ - '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], + new ParagraphBlock([PanelView::text('First '), $badge, PanelView::text(' last')], null), + new ParagraphBlock([], null), + new EmptyStateBlock( + 'Empty', + [ + new ParagraphBlock([PanelView::text('One')], null), + new ParagraphBlock([PanelView::text('Two')], 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]], + self::assertEquals( + [new ParagraphBlock([PanelView::text('A'), PanelView::text('B')], null)], PanelView::create()->paragraph(first: 'A', second: 'B')->blocks(), 'Named arguments must not leak keys into the content list.', ); diff --git a/tests/PanelViewTest.php b/tests/PanelViewTest.php index 55eef16..4dce96e 100644 --- a/tests/PanelViewTest.php +++ b/tests/PanelViewTest.php @@ -7,15 +7,29 @@ use InvalidArgumentException; use PHPForge\Debug\{ColumnStyle, PanelView, Tone}; use PHPForge\Debug\Exception\PanelViewMessage; -use PHPForge\Debug\Tests\Provider\{InlineScalarProvider, LinkTargetProvider}; +use PHPForge\Debug\Presenter\{ + BadgeInline, + FieldEntry, + HeadingBlock, + LinkInline, + OverviewBlock, + ParagraphBlock, + SummaryMetric, + TableBlock, + TextInline, + TextStyle, + ToolbarMetric, + ValueInline, +}; +use PHPForge\Debug\Tests\Provider\InlineScalarProvider; use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\TestCase; use stdClass; /** - * Unit tests for the validated shapes {@see PanelView} exports to the host renderer. + * Unit tests for the value objects {@see PanelView} exports to the host renderer. * - * {@see InlineScalarProvider} and {@see LinkTargetProvider} for test case data providers. + * {@see InlineScalarProvider} for test case data providers. */ final class PanelViewTest extends TestCase { @@ -30,133 +44,113 @@ public function testDefaultsPreserveNonIntrusivePresentation(): void PanelView::create()->isActive(), 'A described panel must be active by default.', ); - self::assertSame( + self::assertEquals( [ - ['kind' => 'heading', 'title' => 'Title', 'section' => false], - ['kind' => 'overview', 'fields' => [], 'compact' => false], - [ - 'kind' => 'table', - 'headers' => [], - 'rows' => [], - 'styles' => [], - 'collapsible' => false, - 'filterable' => false, - ], + new HeadingBlock('Title', false), + new OverviewBlock([], false), + new TableBlock([], [], [], false, false), ], $view->blocks(), 'Every presentation hint must stay opt-in.', ); } - public function testForgedSqlAndTraceValuesSurviveTheInlineRebuild(): void - { - self::assertSame( - [ - [ - 'kind' => 'paragraph', - 'content' => [ - ['kind' => 'text', 'value' => 'SELECT 1', 'style' => 'sql'], - ['kind' => 'trace', 'frames' => [['file' => '/app/x.php']]], - ], - 'tone' => null, - ], - ], - PanelView::create() - ->paragraph( - ['kind' => 'text', 'value' => 'SELECT 1', 'style' => 'sql'], - ['kind' => 'trace', 'frames' => [['file' => '/app/x.php']]], - ) - ->blocks(), - 'Both new inline values must survive the rebuild unchanged.', - ); - } - public function testInlineFactoriesDescribeContentStyleAndTone(): void { - self::assertSame( - ['kind' => 'text', 'value' => '', 'style' => 'plain'], + self::assertEquals( + new TextInline('', TextStyle::PLAIN), PanelView::text(''), 'Text must stay unescaped and unstyled.', ); - self::assertSame( - ['kind' => 'text', 'value' => 'v', 'style' => 'strong'], + self::assertEquals( + new TextInline('v', TextStyle::STRONG), PanelView::strong('v'), 'Emphasis must remain semantic.', ); - self::assertSame( - ['kind' => 'text', 'value' => 'v', 'style' => 'code'], + self::assertEquals( + new TextInline('v', TextStyle::CODE), PanelView::code('v'), 'Source code must remain semantic.', ); - self::assertSame( - ['kind' => 'text', 'value' => 'v', 'style' => 'preview'], + self::assertEquals( + new TextInline('v', TextStyle::PREVIEW), PanelView::preview('v'), 'Clamping must be requested explicitly.', ); - self::assertSame( - ['kind' => 'badge', 'label' => 'shared', 'tone' => Tone::INFO], + self::assertEquals( + new TextInline('SELECT 1', TextStyle::SQL), + PanelView::sql('SELECT 1'), + 'Statement highlighting must be requested explicitly.', + ); + self::assertEquals( + new BadgeInline('shared', Tone::INFO), PanelView::badge('shared', Tone::INFO), 'Badges must retain their text and tone.', ); - self::assertSame( - ['kind' => 'badge', 'label' => 'b', 'tone' => Tone::MUTED], + self::assertEquals( + new BadgeInline('b', Tone::MUTED), PanelView::badge('b'), 'Badges must default to a muted tone.', ); - self::assertSame( - ['kind' => 'value', 'value' => ['id' => 1], 'typeOnly' => false], + self::assertEquals( + new ValueInline(['id' => 1], false), PanelView::value(['id' => 1]), 'Diagnostic values must not be flattened.', ); - self::assertSame( - ['kind' => 'value', 'value' => null, 'typeOnly' => true], + self::assertEquals( + new ValueInline(null, true), PanelView::value(null, typeOnly: true), 'Type-only presentation must be explicit.', ); - self::assertSame( - ['kind' => 'link', 'label' => 'View full phpinfo', 'href' => '/debug/php-info', 'external' => false], + self::assertEquals( + new LinkInline('View full phpinfo', '/debug/php-info', false), PanelView::link('View full phpinfo', '/debug/php-info'), 'Links must stay in the same browsing context by default.', ); - self::assertSame( - ['kind' => 'link', 'label' => 'Docs', 'href' => 'https://example.test/d', 'external' => true], + self::assertEquals( + new LinkInline('Docs', 'https://example.test/d', true), PanelView::link('Docs', 'https://example.test/d', true), 'A new browsing context must be requested explicitly.', ); self::assertSame( - ['kind' => 'text', 'value' => 'SELECT 1', 'style' => 'sql'], - PanelView::sql('SELECT 1'), - 'Statement highlighting must be requested explicitly.', - ); - self::assertSame( - ['kind' => 'trace', 'frames' => [['file' => '/app/x.php', 'line' => 7], ['0' => 'bare']]], - PanelView::trace([['file' => '/app/x.php', 'line' => 7], ['bare']]), + [['file' => '/app/x.php', 'line' => 7], ['bare']], + PanelView::trace([['file' => '/app/x.php', 'line' => 7], ['bare']])->frames, 'Frames must travel as captured fields, with keys normalized to strings.', ); } - #[DataProviderExternal(LinkTargetProvider::class, 'accepted')] - public function testLinkTargetsWithoutAnExecutableSchemeAreAccepted(string $href): void + public function testInlineValuesTravelThroughContentWithoutRebuilding(): void { + $sql = PanelView::sql('SELECT 1'); + $trace = PanelView::trace([['file' => '/app/x.php']]); + + $blocks = PanelView::create() + ->paragraph($sql, $trace) + ->blocks(); + + $block = $blocks[0] ?? null; + + self::assertInstanceOf( + ParagraphBlock::class, + $block, + 'Content must reach the host as a paragraph.', + ); + + $content = $block->content; + self::assertSame( - [ - [ - 'kind' => 'overview', - 'fields' => [ - [ - 'label' => 'Target', - 'value' => ['kind' => 'link', 'label' => 'Open', 'href' => $href, 'external' => false], - ], - ], - 'compact' => false, - ], - ], - PanelView::create()->overview(['Target' => PanelView::link('Open', $href)])->blocks(), - 'An accepted target must travel unmodified.', + $sql, + $content[0] ?? null, + 'The statement must travel as the same object.', + ); + self::assertSame( + $trace, + $content[1] ?? null, + 'The trace must travel as the same object.', ); } - public function testMetricsAndFieldsShareOneLabelAndValueShape(): void + public function testMetricsAndFieldsCarryTheirLabelsAndValues(): void { $view = PanelView::create() ->summary(' prop', 1) @@ -164,53 +158,36 @@ public function testMetricsAndFieldsShareOneLabelAndValueShape(): void ->toolbar('Hits', 3) ->overview(['Driver' => 'redis']); - self::assertSame( + self::assertEquals( [ - ['label' => ' prop', 'value' => ['kind' => 'text', 'value' => '1', 'style' => 'strong']], - ['label' => '', 'value' => ['kind' => 'text', 'value' => '2.5', 'style' => 'plain']], + new SummaryMetric(' prop', new TextInline('1', TextStyle::STRONG)), + new SummaryMetric('', new TextInline('2.5', TextStyle::PLAIN)), ], $view->summaryMetrics(), 'Emphasis must travel as the inline text style.', ); - self::assertSame( - [['label' => 'Hits', 'value' => ['kind' => 'text', 'value' => '3', 'style' => 'plain']]], + self::assertEquals( + [new ToolbarMetric('Hits', '3')], $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, - ], - ], + self::assertEquals( + [new OverviewBlock([new FieldEntry('Driver', new TextInline('redis', TextStyle::PLAIN))], false)], $view->blocks(), - 'Overview fields must reuse the metric shape.', + 'Overview fields must carry their label beside the inline value.', ); } - /** - * @param array{kind: 'text', value: string, style: 'plain'} $expected - */ #[DataProviderExternal(InlineScalarProvider::class, 'plainText')] - public function testScalarContentBecomesPlainInlineText(mixed $value, array $expected): void + public function testScalarContentBecomesPlainInlineText(mixed $value, TextInline $expected): void { - self::assertSame( - [['kind' => 'paragraph', 'content' => [$expected], 'tone' => null]], + self::assertEquals( + [new ParagraphBlock([$expected], 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, - ], - ], + self::assertEquals( + [new OverviewBlock([new FieldEntry('Field', $expected)], false)], PanelView::create()->overview(['Field' => $value])->blocks(), 'Overview fields must convert identically.', ); @@ -220,15 +197,15 @@ public function testStringKeyedSpreadContentStaysAList(): void { $parts = ['first' => 'A', 'second' => PanelView::code('B')]; - $content = [PanelView::text('A'), PanelView::code('B')]; + $content = [new TextInline('A', TextStyle::PLAIN), new TextInline('B', TextStyle::CODE)]; - self::assertSame( - [['kind' => 'paragraph', 'content' => $content, 'tone' => null]], + self::assertEquals( + [new ParagraphBlock($content, null)], PanelView::create()->paragraph(...$parts)->blocks(), 'String keys must not reach the paragraph content.', ); - self::assertSame( - [['kind' => 'paragraph', 'content' => $content, 'tone' => Tone::WARNING]], + self::assertEquals( + [new ParagraphBlock($content, Tone::WARNING)], PanelView::create()->callout(Tone::WARNING, ...$parts)->blocks(), 'String keys must not reach the callout content.', ); @@ -243,79 +220,28 @@ public function testTableKeepsEveryStyledColumnUnderItsIndex(): void styles: [0 => ColumnStyle::MONOSPACE, 2 => ColumnStyle::NUMBER], ); - self::assertSame( + self::assertEquals( [ - [ - '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, - 'filterable' => false, - ], + new TableBlock( + ['Key', 'Value', 'Hits'], + [ + [ + new TextInline('home', TextStyle::PLAIN), + new TextInline('cached', TextStyle::PLAIN), + new TextInline('3', TextStyle::PLAIN), + ], + ], + [0 => ColumnStyle::MONOSPACE, 2 => ColumnStyle::NUMBER], + false, + false, + ), ], $view->blocks(), 'Every styled column must survive, not only the first.', ); } - public function testThrowInvalidArgumentExceptionForAssociativeParagraph(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - PanelViewMessage::PARAGRAPH_CONTENT_INVALID->getMessage(), - ); - - PanelView::create()->emptyState('Empty', ['first' => 'A']); - } - - #[DataProviderExternal(LinkTargetProvider::class, 'normalized')] - public function testThrowInvalidArgumentExceptionForBrowserNormalizedLinkTarget(string $href): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - PanelViewMessage::LINK_TARGET_NORMALIZED->getMessage(), - ); - - PanelView::link( - 'Open', - $href, - ); - } - - #[DataProviderExternal(LinkTargetProvider::class, 'rejected')] - public function testThrowInvalidArgumentExceptionForExecutableLinkTarget(string $href, string $scheme): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - PanelViewMessage::LINK_TARGET_SCHEME_INVALID->getMessage($scheme), - ); - - PanelView::link( - 'Open', - $href, - ); - } - - public function testThrowInvalidArgumentExceptionForForgedExecutableLink(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - PanelViewMessage::LINK_TARGET_SCHEME_INVALID->getMessage('javascript'), - ); - - PanelView::create() - ->paragraph( - [ - 'kind' => 'link', - 'label' => 'Open', - 'href' => 'javascript:alert(1)', - 'external' => false, - ], - ); - } - - public function testThrowInvalidArgumentExceptionForForgedInlineValue(): void + public function testThrowInvalidArgumentExceptionForArrayContent(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( @@ -325,24 +251,24 @@ public function testThrowInvalidArgumentExceptionForForgedInlineValue(): void PanelView::create()->paragraph(['kind' => 'text']); } - public function testThrowInvalidArgumentExceptionForNonArrayTraceFrame(): void + public function testThrowInvalidArgumentExceptionForAssociativeParagraph(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - PanelViewMessage::TRACE_FRAME_INVALID->getMessage(), + PanelViewMessage::PARAGRAPH_CONTENT_INVALID->getMessage(), ); - PanelView::trace(['not a frame']); + PanelView::create()->emptyState('Empty', ['first' => 'A']); } - public function testThrowInvalidArgumentExceptionForNonColumnStyle(): void + public function testThrowInvalidArgumentExceptionForAssociativeRow(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - PanelViewMessage::COLUMN_STYLE_INVALID->getMessage(ColumnStyle::class), + PanelViewMessage::TABLE_ROW_WIDTH_INVALID->getMessage(), ); - PanelView::create()->table(['One'], [['a']], styles: [0 => 'pill']); + PanelView::create()->table(['One'], [['first' => 'a']]); } public function testThrowInvalidArgumentExceptionForNonInlineObject(): void @@ -364,47 +290,4 @@ public function testThrowInvalidArgumentExceptionForNonListRow(): void PanelView::create()->table(['One'], ['not a row']); } - - public function testThrowInvalidArgumentExceptionForNonStringHeader(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - PanelViewMessage::TABLE_HEADER_INVALID->getMessage(), - ); - - PanelView::create()->table([1], [[1]]); - } - - public function testThrowInvalidArgumentExceptionForRowWidthMismatch(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - PanelViewMessage::TABLE_ROW_WIDTH_INVALID->getMessage(), - ); - - PanelView::create()->table(['One'], [[]]); - } - - public function testThrowInvalidArgumentExceptionForUnknownStyledColumn(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - PanelViewMessage::COLUMN_STYLE_KEY_INVALID->getMessage(), - ); - - PanelView::create()->table(['One'], [['a']], styles: [1 => ColumnStyle::PILL]); - } - - public function testThrowInvalidArgumentExceptionForUnparsableLinkTarget(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - PanelViewMessage::LINK_TARGET_UNPARSABLE->getMessage(), - ); - - PanelView::link( - 'Open', - 'http://:80', - ); - } } diff --git a/tests/Presenter/LinkInlineTest.php b/tests/Presenter/LinkInlineTest.php new file mode 100644 index 0000000..2eb986f --- /dev/null +++ b/tests/Presenter/LinkInlineTest.php @@ -0,0 +1,62 @@ +href, + 'An accepted target must travel unmodified.', + ); + } + + #[DataProviderExternal(LinkTargetProvider::class, 'normalized')] + public function testThrowInvalidArgumentExceptionForBrowserNormalizedLinkTarget(string $href): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::LINK_TARGET_NORMALIZED->getMessage(), + ); + + new LinkInline('Open', $href, false); + } + + #[DataProviderExternal(LinkTargetProvider::class, 'rejected')] + public function testThrowInvalidArgumentExceptionForExecutableLinkTarget(string $href, string $scheme): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::LINK_TARGET_SCHEME_INVALID->getMessage($scheme), + ); + + new LinkInline('Open', $href, false); + } + + public function testThrowInvalidArgumentExceptionForUnparsableLinkTarget(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::LINK_TARGET_UNPARSABLE->getMessage(), + ); + + new LinkInline('Open', 'http://:80', false); + } +} diff --git a/tests/Presenter/TableBlockTest.php b/tests/Presenter/TableBlockTest.php new file mode 100644 index 0000000..2f03625 --- /dev/null +++ b/tests/Presenter/TableBlockTest.php @@ -0,0 +1,118 @@ + ColumnStyle::MONOSPACE], + true, + true, + ); + + self::assertSame( + ['Key', 'Value'], + $block->headers, + 'Headings must stay in declaration order.', + ); + self::assertSame( + [[$cell, $badge]], + $block->rows, + 'Cells must travel as the same objects.', + ); + self::assertSame( + [0 => ColumnStyle::MONOSPACE], + $block->styles, + 'Styles must stay keyed by their column index.', + ); + self::assertTrue( + $block->collapsible, + 'Collapsing must be requested explicitly.', + ); + self::assertTrue( + $block->filterable, + 'Filtering must be requested explicitly.', + ); + } + + public function testThrowInvalidArgumentExceptionForNegativeStyledColumn(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::COLUMN_STYLE_KEY_INVALID->getMessage(), + ); + + new TableBlock(['One'], [], [-1 => ColumnStyle::PILL], false, false); + } + + public function testThrowInvalidArgumentExceptionForNonColumnStyle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::COLUMN_STYLE_INVALID->getMessage(ColumnStyle::class), + ); + + new TableBlock(['One'], [], [0 => 'pill'], false, false); + } + + public function testThrowInvalidArgumentExceptionForNonStringHeader(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::TABLE_HEADER_INVALID->getMessage(), + ); + + new TableBlock([1], [], [], false, false); + } + + public function testThrowInvalidArgumentExceptionForRowWidthMismatch(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::TABLE_ROW_WIDTH_INVALID->getMessage(), + ); + + new TableBlock(['One'], [[]], [], false, false); + } + + public function testThrowInvalidArgumentExceptionForStringStyledColumn(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::COLUMN_STYLE_KEY_INVALID->getMessage(), + ); + + new TableBlock(['One'], [], ['first' => ColumnStyle::PILL], false, false); + } + + public function testThrowInvalidArgumentExceptionForUnknownStyledColumn(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::COLUMN_STYLE_KEY_INVALID->getMessage(), + ); + + new TableBlock(['One'], [], [1 => ColumnStyle::PILL], false, false); + } +} diff --git a/tests/Presenter/TraceInlineTest.php b/tests/Presenter/TraceInlineTest.php new file mode 100644 index 0000000..86d8db5 --- /dev/null +++ b/tests/Presenter/TraceInlineTest.php @@ -0,0 +1,40 @@ + '/app/x.php', 'line' => 7], ['bare']], + (new TraceInline([['file' => '/app/x.php', 'line' => 7], ['bare']]))->frames, + 'Fields must travel as captured, with keys normalized to strings.', + ); + self::assertSame( + [], + (new TraceInline([]))->frames, + 'A call site without frames must stay empty.', + ); + } + + public function testThrowInvalidArgumentExceptionForNonArrayTraceFrame(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + PanelViewMessage::TRACE_FRAME_INVALID->getMessage(), + ); + + new TraceInline(['not a frame']); + } +} diff --git a/tests/Provider/InlineScalarProvider.php b/tests/Provider/InlineScalarProvider.php index 366f114..6b62cf5 100644 --- a/tests/Provider/InlineScalarProvider.php +++ b/tests/Provider/InlineScalarProvider.php @@ -4,6 +4,8 @@ namespace PHPForge\Debug\Tests\Provider; +use PHPForge\Debug\Presenter\TextInline; +use PHPForge\Debug\Presenter\TextStyle; use PHPForge\Debug\Tests\PanelViewTest; /** @@ -12,15 +14,15 @@ final class InlineScalarProvider { /** - * @return iterable + * @return iterable */ 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']]; + yield 'false' => [false, new TextInline('false', TextStyle::PLAIN)]; + yield 'float' => [1.5, new TextInline('1.5', TextStyle::PLAIN)]; + yield 'int' => [7, new TextInline('7', TextStyle::PLAIN)]; + yield 'null' => [null, new TextInline('null', TextStyle::PLAIN)]; + yield 'string' => ['', new TextInline('', TextStyle::PLAIN)]; + yield 'true' => [true, new TextInline('true', TextStyle::PLAIN)]; } } diff --git a/tests/Provider/LinkTargetProvider.php b/tests/Provider/LinkTargetProvider.php index 70593af..0e7f5a7 100644 --- a/tests/Provider/LinkTargetProvider.php +++ b/tests/Provider/LinkTargetProvider.php @@ -4,10 +4,10 @@ namespace PHPForge\Debug\Tests\Provider; -use PHPForge\Debug\Tests\PanelViewTest; +use PHPForge\Debug\Tests\Presenter\LinkInlineTest; /** - * Provides link targets whose acceptance or rejection by {@see PanelViewTest} defines the safe-scheme contract. + * Provides link targets whose acceptance or rejection by {@see LinkInlineTest} defines the safe-scheme contract. */ final class LinkTargetProvider { @@ -20,6 +20,7 @@ public static function accepted(): iterable yield 'colon inside a fragment' => ['#a:b']; yield 'colon inside a query' => ['?at=a:b']; yield 'colon opening the target' => [':relative']; + yield 'fragment naming a card anchor' => ['#app-asset']; yield 'fragment' => ['#queries']; yield 'http' => ['http://example.test/']; yield 'https with uppercase scheme' => ['HTTPS://example.test/']; diff --git a/tests/Provider/MalformedEntryProvider.php b/tests/Provider/MalformedEntryProvider.php deleted file mode 100644 index 0f4b364..0000000 --- a/tests/Provider/MalformedEntryProvider.php +++ /dev/null @@ -1,117 +0,0 @@ - - */ - public static function entries(): iterable - { - yield 'fact built by another factory' => [ - static fn(): PanelView => PanelView::create()->facts( - ['kind' => 'pill', 'label' => 'Charset', 'value' => 'UTF-8'], - ), - 'fact', - ]; - yield 'fact without a label' => [ - static fn(): PanelView => PanelView::create()->facts( - ['kind' => 'fact', 'label' => 8, 'value' => 'UTF-8'], - ), - 'fact', - ]; - yield 'fact without a value' => [ - static fn(): PanelView => PanelView::create()->facts( - ['kind' => 'fact', 'label' => 'Charset', 'value' => 8], - ), - 'fact', - ]; - yield 'empty fact' => [ - static fn(): PanelView => PanelView::create()->facts([]), - 'fact', - ]; - yield 'package built by another factory' => [ - static fn(): PanelView => PanelView::create()->manifest( - 'yiisoft/', - ['kind' => 'fact', 'name' => 'aliases', 'version' => 'v3.1.1'], - ), - 'package', - ]; - yield 'package without a name' => [ - static fn(): PanelView => PanelView::create()->manifest( - 'yiisoft/', - ['kind' => 'package', 'name' => 1, 'version' => 'v3.1.1'], - ), - 'package', - ]; - yield 'package without a version' => [ - static fn(): PanelView => PanelView::create()->manifest( - 'yiisoft/', - ['kind' => 'package', 'name' => 'aliases', 'version' => 3], - ), - 'package', - ]; - yield 'empty package' => [ - static fn(): PanelView => PanelView::create()->manifest('yiisoft/', []), - 'package', - ]; - yield 'pill built by another factory' => [ - static fn(): PanelView => PanelView::create()->pills( - ['kind' => 'fact', 'label' => 'APCu', 'state' => 'on', 'enabled' => true], - ), - 'pill', - ]; - yield 'pill without a label' => [ - static fn(): PanelView => PanelView::create()->pills( - ['kind' => 'pill', 'label' => 1, 'state' => 'on', 'enabled' => true], - ), - 'pill', - ]; - yield 'pill without a state' => [ - static fn(): PanelView => PanelView::create()->pills( - ['kind' => 'pill', 'label' => 'APCu', 'state' => 1, 'enabled' => true], - ), - 'pill', - ]; - yield 'pill without a boolean flag' => [ - static fn(): PanelView => PanelView::create()->pills( - ['kind' => 'pill', 'label' => 'APCu', 'state' => 'on', 'enabled' => 'yes'], - ), - 'pill', - ]; - yield 'readout built by another factory' => [ - static fn(): PanelView => PanelView::create()->readouts( - ['kind' => 'fact', 'label' => 'Yii', 'value' => '3', 'caption' => 'framework'], - ), - 'readout', - ]; - yield 'readout without a label' => [ - static fn(): PanelView => PanelView::create()->readouts( - ['kind' => 'readout', 'label' => 1, 'value' => '3', 'caption' => 'framework'], - ), - 'readout', - ]; - yield 'readout without a value' => [ - static fn(): PanelView => PanelView::create()->readouts( - ['kind' => 'readout', 'label' => 'Yii', 'value' => 3, 'caption' => 'framework'], - ), - 'readout', - ]; - yield 'readout without a caption' => [ - static fn(): PanelView => PanelView::create()->readouts( - ['kind' => 'readout', 'label' => 'Yii', 'value' => '3', 'caption' => 0], - ), - 'readout', - ]; - } -}