diff --git a/CHANGELOG.md b/CHANGELOG.md index f32041e..2ae18de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,3 +45,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - refactor: centralize exception messages and panel titles in typed enums while preserving diagnostics, labels, and configurable names. - refactor: use shared `PanelIcon` enum values for built-in panel SVG keys. - fix: improve UI contrast, focus, deep links, history alignment, shared asset sizing, and local rebuild documentation. +- feat!: split panel texts into Event, Log, Profile, and Inertia enums; add the event detail cell, Format::typeOf(), and PageSize::selectorFor(). diff --git a/README.md b/README.md index c0b174d..86eb647 100644 --- a/README.md +++ b/README.md @@ -189,21 +189,25 @@ and pagination. Event/source shortcuts show whole-capture counts and retain the `EventInspectorRenderer::renderControls()` renders group shortcuts and capture guidance. Adapters reuse one `EventSequence` for the complete capture and call `renderTimeCell()` and `renderEventCell()` for each visible row. -Append `renderDetailRow()` immediately after each event row, passing the table's column count. The native disclosure -reveals context and source trace across the table width, side by side on larger screens and stacked on narrow screens. -Diagnostics do not repeat the timestamp, event name, class, source, or static flag already available in the table. -There is no standalone execution-flow renderer or secondary event table. - -`PanelMessage` centralizes static presentation text, starting with Events. Shared labels have unprefixed case names; -event-specific guidance and capture-state descriptions use `EVENT_`. Pass cases directly to `content()` without -`->value`; `ui-awesome/html-mixin ^0.8.1` normalizes the enum value before HTML encoding. Captured values, filter keys, -and dynamic text remain outside the catalog. The rendered wording and snapshot format are unchanged. +Append `renderDetailRow()` immediately after each event row, passing the table's column count. Adapters that build +their own rows, for example through a grid widget's after-row callback, call `renderDetailCell()` to obtain the +disclosure content without the row wrapper. The native disclosure reveals context and source trace across the table +width, side by side on larger screens and stacked on narrow screens. Diagnostics do not repeat the timestamp, event +name, class, source, or static flag already available in the table. There is no standalone execution-flow renderer or +secondary event table. + +`PanelMessage` holds only the labels shared by every panel (`CONTEXT`, `GROUP_FILTERS`, `SOURCE_TRACE`). Each panel +owns its texts in an enum next to its code (`Panel\Event\EventMessage`, `Panel\Log\LogMessage`, +`Panel\Profile\ProfileMessage`, `Panel\Inertia\InertiaMessage`) with unprefixed case names. Pass cases directly to +`content()` without `->value`; `ui-awesome/html-mixin ^0.8.1` normalizes the enum value before HTML encoding. Captured +values, filter keys, and dynamic text remain outside the catalogs. The rendered wording and snapshot format are +unchanged. ```php -use PHPForge\Debug\Panel\PanelMessage; +use PHPForge\Debug\Panel\Event\EventMessage; use UIAwesome\Html\Flow\P; -echo P::tag()->content(PanelMessage::EVENT_CAPTURE_GUIDANCE)->render(); +echo P::tag()->content(EventMessage::CAPTURE_GUIDANCE)->render(); ``` `EventRow::withInspection()` creates an enriched copy without changing the captured row. `EventInspection` supplies diff --git a/src/Data/PageSize.php b/src/Data/PageSize.php index 952fd69..5a25827 100644 --- a/src/Data/PageSize.php +++ b/src/Data/PageSize.php @@ -75,6 +75,16 @@ public static function resolve(string|null $raw, int $default = self::DEFAULT): return min($size, self::MAX); } + /** + * Renders the page-size selector for the `per-page` value found in the query parameters. + * + * @param array $queryParams Query parameters already normalized by the panel. + */ + public static function selectorFor(array $queryParams): string + { + return self::selectorHtml(self::current(QueryInput::scalar($queryParams, 'per-page'))); + } + /** * Renders the inline page-size selector shown in the grid summary header. * diff --git a/src/Helper/Format.php b/src/Helper/Format.php index e9da7e8..7ba09b0 100644 --- a/src/Helper/Format.php +++ b/src/Helper/Format.php @@ -4,11 +4,19 @@ namespace PHPForge\Debug\Helper; +use function count; +use function gettype; +use function is_array; +use function is_bool; +use function is_float; +use function is_int; +use function is_string; use function rtrim; use function sprintf; +use function strlen; /** - * Formats numeric values for display in debug-panel views and toolbar chips. + * Formats values and type labels for display in debug-panel views and toolbar chips. */ final class Format { @@ -42,4 +50,24 @@ public static function cssPercent(float $value): string return "{$rendered}%"; } + + /** + * Returns the display label of a value's type, with the element count for arrays and the byte length for strings. + * + * @param mixed $value JSON-safe value to describe. + * + * @return string Type label such as `array(3)`, `string(16)`, `int`, `float`, `bool`, or `null`. + */ + public static function typeOf(mixed $value): string + { + return match (true) { + is_array($value) => 'array(' . count($value) . ')', + is_string($value) => 'string(' . strlen($value) . ')', + is_int($value) => 'int', + is_float($value) => 'float', + is_bool($value) => 'bool', + $value === null => 'null', + default => gettype($value), + }; + } } diff --git a/src/Panel/Dump/DumpRow.php b/src/Panel/Dump/DumpRow.php index 1d47024..e3a0389 100644 --- a/src/Panel/Dump/DumpRow.php +++ b/src/Panel/Dump/DumpRow.php @@ -9,7 +9,7 @@ /** * Typed dump row narrowed once from the Yii logger tuple and persisted in that form. * - * @phpstan-import-type LogMessage from \PHPForge\Debug\Panel\Log\LogSnapshot + * @phpstan-import-type LogTuple from \PHPForge\Debug\Panel\Log\LogSnapshot */ final readonly class DumpRow implements PanelRow { @@ -61,7 +61,7 @@ public static function fromArray(mixed $data, string $path): self /** * Converts one canonical logger tuple into a typed row. * - * @param LogMessage $message Logger tuple `[message, level, category, timestamp, traces]`. + * @param LogTuple $message Logger tuple `[message, level, category, timestamp, traces]`. */ public static function fromLoggerTuple(array $message): self { diff --git a/src/Panel/Dump/DumpSnapshot.php b/src/Panel/Dump/DumpSnapshot.php index 6aacd54..6e71f20 100644 --- a/src/Panel/Dump/DumpSnapshot.php +++ b/src/Panel/Dump/DumpSnapshot.php @@ -11,7 +11,7 @@ /** * Canonical Dump panel snapshot holding the captured rows in their typed form. * - * @phpstan-import-type LogMessage from \PHPForge\Debug\Panel\Log\LogSnapshot + * @phpstan-import-type LogTuple from \PHPForge\Debug\Panel\Log\LogSnapshot */ final readonly class DumpSnapshot implements PanelSnapshot { @@ -23,7 +23,7 @@ public function __construct(private array $entries) {} /** * Converts canonical logger tuples into typed rows. * - * @param list $messages Logger tuples in capture order. + * @param list $messages Logger tuples in capture order. */ public static function capture(array $messages): self { diff --git a/src/Panel/Event/EventInspectorRenderer.php b/src/Panel/Event/EventInspectorRenderer.php index dcdbb47..3233dd5 100644 --- a/src/Panel/Event/EventInspectorRenderer.php +++ b/src/Panel/Event/EventInspectorRenderer.php @@ -44,32 +44,32 @@ public static function renderControls( ->html( P::tag() ->class('yii-debug-muted') - ->content(PanelMessage::EVENT_INSPECTION_GUIDANCE), + ->content(EventMessage::INSPECTION_GUIDANCE), Details::tag() ->class('yii-debug-event-coverage') ->html( Summary::tag()->content(PanelMessage::GROUP_FILTERS), - self::groups($allRows, $filterUrl, $eventAttribute, PanelMessage::EVENT_GROUP_BY_EVENT), - self::groups($allRows, $filterUrl, 'senderClass', PanelMessage::EVENT_GROUP_BY_SOURCE), + self::groups($allRows, $filterUrl, $eventAttribute, EventMessage::GROUP_BY_EVENT), + self::groups($allRows, $filterUrl, 'senderClass', EventMessage::GROUP_BY_SOURCE), ), Details::tag() ->class('yii-debug-event-coverage yii-debug-muted') ->html( - Summary::tag()->content(PanelMessage::EVENT_CAPTURE_COVERAGE), + Summary::tag()->content(EventMessage::CAPTURE_COVERAGE), P::tag()->content($coverage), - P::tag()->content(PanelMessage::EVENT_CAPTURE_GUIDANCE), - P::tag()->content(PanelMessage::EVENT_TIMING_GUIDANCE), + P::tag()->content(EventMessage::CAPTURE_GUIDANCE), + P::tag()->content(EventMessage::TIMING_GUIDANCE), ), ) ->render(); } /** - * Renders a full-width diagnostic row controlled by the preceding event disclosure. + * Renders the diagnostic disclosure content of one event without the table row wrapper. * - * @param int<1, 1000> $columns Number of visible columns in the adapter table. + * {@see renderDetailRow()} wraps this content in a full-width table row. */ - public static function renderDetailRow(EventRow $row, EventSequence $sequence, int $columns): string + public static function renderDetailCell(EventRow $row, EventSequence $sequence): string { $inspection = $row->inspection(); $index = $sequence->index($row); @@ -84,67 +84,76 @@ public static function renderDetailRow(EventRow $row, EventSequence $sequence, i } $contextStatus = match ($inspection?->getContextStatus()) { - 'captured' => PanelMessage::EVENT_CONTEXT_CAPTURED, - 'unsupported' => PanelMessage::EVENT_CONTEXT_UNSUPPORTED, - 'failed' => PanelMessage::EVENT_CONTEXT_FAILED, - default => PanelMessage::EVENT_CONTEXT_NOT_CAPTURED, + 'captured' => EventMessage::CONTEXT_CAPTURED, + 'unsupported' => EventMessage::CONTEXT_UNSUPPORTED, + 'failed' => EventMessage::CONTEXT_FAILED, + default => EventMessage::CONTEXT_NOT_CAPTURED, }; $traceStatus = match ($inspection?->getTraceStatus()) { - 'captured' => PanelMessage::EVENT_TRACE_CAPTURED, - 'failed' => PanelMessage::EVENT_TRACE_FAILED, - default => PanelMessage::EVENT_TRACE_NOT_CAPTURED, + 'captured' => EventMessage::TRACE_CAPTURED, + 'failed' => EventMessage::TRACE_FAILED, + default => EventMessage::TRACE_NOT_CAPTURED, }; + return Div::tag() + ->id("event-{$index}-detail") + ->class('yii-debug-event-detail') + ->role('region') + ->addAriaAttribute('label', "Diagnostics for event #{$index}") + ->html( + Div::tag()->class('yii-debug-event-context') + ->html( + Strong::tag()->content(PanelMessage::CONTEXT), + P::tag()->content($contextStatus), + ...$context === [] + ? [] + : [ + Dl::tag() + ->class('yii-debug-event-metadata') + ->html(...$context), + ], + ), + Div::tag() + ->class('yii-debug-event-trace') + ->html( + Strong::tag()->content(PanelMessage::SOURCE_TRACE), + P::tag()->content($traceStatus), + ...$trace === [] ? [] : [Pre::tag()->content(implode("\n", $trace))], + ), + Div::tag() + ->class('yii-debug-event-detail-footer') + ->html( + A::tag() + ->class('yii-debug-event-permalink') + ->href("#event-{$index}") + ->content("Link to event #{$index}"), + ...$phase === '' ? [] : [ + Span::tag() + ->class('yii-debug-muted') + ->content( + $inspection?->getPairId() === null + ? EventMessage::UNMATCHED_ENTRY + : "Lifecycle correlation: scope #{$inspection->getPairId()}", + ), + ], + ), + ) + ->render(); + } + + /** + * Renders a full-width diagnostic row controlled by the preceding event disclosure. + * + * @param int<1, 1000> $columns Number of visible columns in the adapter table. + */ + public static function renderDetailRow(EventRow $row, EventSequence $sequence, int $columns): string + { return Tr::tag() ->class('yii-debug-event-detail-row') ->html( Td::tag() ->colspan($columns) - ->html( - Div::tag() - ->id("event-{$index}-detail") - ->class('yii-debug-event-detail') - ->role('region') - ->addAriaAttribute('label', "Diagnostics for event #{$index}") - ->html( - Div::tag()->class('yii-debug-event-context') - ->html( - Strong::tag()->content(PanelMessage::CONTEXT), - P::tag()->content($contextStatus), - ...$context === [] - ? [] - : [ - Dl::tag() - ->class('yii-debug-event-metadata') - ->html(...$context), - ], - ), - Div::tag() - ->class('yii-debug-event-trace') - ->html( - Strong::tag()->content(PanelMessage::SOURCE_TRACE), - P::tag()->content($traceStatus), - ...$trace === [] ? [] : [Pre::tag()->content(implode("\n", $trace))], - ), - Div::tag() - ->class('yii-debug-event-detail-footer') - ->html( - A::tag() - ->class('yii-debug-event-permalink') - ->href("#event-{$index}") - ->content("Link to event #{$index}"), - ...$phase === '' ? [] : [ - Span::tag() - ->class('yii-debug-muted') - ->content( - $inspection?->getPairId() === null - ? PanelMessage::EVENT_UNMATCHED_ENTRY - : "Lifecycle correlation: scope #{$inspection->getPairId()}", - ), - ], - ), - ), - ), + ->html(self::renderDetailCell($row, $sequence)), ) ->render(); } @@ -196,7 +205,7 @@ public static function renderTimeCell(EventRow $row, EventSequence $sequence): s $gap = $sequence->gap($row); $timing = $interval === null - ? ($gap === null ? PanelMessage::EVENT_FIRST_OBSERVATION : sprintf('%+.3f ms gap', $gap)) + ? ($gap === null ? EventMessage::FIRST_OBSERVATION : sprintf('%+.3f ms gap', $gap)) : sprintf('%.3f ms inclusive interval', $interval); return Div::tag() @@ -217,7 +226,7 @@ public static function renderTimeCell(EventRow $row, EventSequence $sequence): s * @param list $rows * @param (Closure(string, string): string)|null $filterUrl */ - private static function groups(array $rows, Closure|null $filterUrl, string $attribute, PanelMessage $label): Div + private static function groups(array $rows, Closure|null $filterUrl, string $attribute, EventMessage $label): Div { $groups = []; diff --git a/src/Panel/Event/EventMessage.php b/src/Panel/Event/EventMessage.php new file mode 100644 index 0000000..8558eaf --- /dev/null +++ b/src/Panel/Event/EventMessage.php @@ -0,0 +1,121 @@ +dispatch(new MyEvent());'; + + /** + * Explanation of the empty state, describing what the panel records. + */ + case EMPTY_EXPLANATION = 'The Events panel records PSR-14 objects sent through the configured debug ' + . 'dispatcher decorator, so this request completed without dispatching any.'; + + /** + * Headline of the empty state when the request dispatched no event. + */ + case EMPTY_HEADLINE = 'No events dispatched in this request'; + + /** + * Timing label of the time cell for the first observation of the capture. + */ + case FIRST_OBSERVATION = 'First observation'; + + /** + * Label of the whole-capture event shortcuts inside the group filter disclosure. + */ + case GROUP_BY_EVENT = 'By event (whole capture)'; + + /** + * Label of the whole-capture source shortcuts inside the group filter disclosure. + */ + case GROUP_BY_SOURCE = 'By source (whole capture)'; + + /** + * Muted guidance above the events table, explaining how to read the diagnostics. + */ + case INSPECTION_GUIDANCE = 'Open an event for diagnostics. Times and observation numbers refer to the ' + . 'original capture, regardless of sorting or filtering.'; + + /** + * Explanation of the no-match state, offering the filter reset. + */ + case NO_MATCH_EXPLANATION = 'Adjust or clear the filters to show the dispatched events.'; + + /** + * Headline of the no-match state when the active filters exclude every captured event. + */ + case NO_MATCH_HEADLINE = 'No events match the active filters'; + + /** + * Guidance inside the capture coverage disclosure about offsets, gaps, and lifecycle intervals. + */ + case TIMING_GUIDANCE = 'Offsets are relative to the first captured event. Gaps are not listener durations. ' + . 'Paired lifecycle intervals include nested work and dispatch overhead. A leave marker does not prove success.'; + + /** + * Status of the source trace section in the event detail when a trace was captured. + */ + case TRACE_CAPTURED = 'Argument-free source trace'; + + /** + * Status of the source trace section in the event detail when the capture raised an error. + */ + case TRACE_FAILED = 'Source trace capture failed'; + + /** + * Status of the source trace section in the event detail when trace capture is disabled. + */ + case TRACE_NOT_CAPTURED = 'Not captured (source trace capture is opt-in)'; + + /** + * Footer note of the event detail when a lifecycle marker has no correlated counterpart. + */ + case UNMATCHED_ENTRY = 'No matching entry captured'; +} diff --git a/src/Panel/Inertia/InertiaMessage.php b/src/Panel/Inertia/InertiaMessage.php new file mode 100644 index 0000000..56a58e2 --- /dev/null +++ b/src/Panel/Inertia/InertiaMessage.php @@ -0,0 +1,27 @@ + - * @phpstan-type LogMessage array{ + * @phpstan-type LogTuple array{ * 0: string, * 1: int, * 2: string, @@ -32,7 +32,7 @@ public function __construct(private array $entries) {} /** * Converts canonical logger tuples into typed rows, deriving the previous/next links and the inter-row deltas. * - * @param list $messages Logger tuples in capture order. + * @param list $messages Logger tuples in capture order. */ public static function capture(array $messages): self { diff --git a/src/Panel/PanelMessage.php b/src/Panel/PanelMessage.php index d436463..caf6acb 100644 --- a/src/Panel/PanelMessage.php +++ b/src/Panel/PanelMessage.php @@ -5,30 +5,22 @@ namespace PHPForge\Debug\Panel; /** - * Text shared by debugger panels, with panel-specific cases prefixed by their panel name. + * Text shared by every debugger panel. */ enum PanelMessage: string { + /** + * Heading of the context section in a detail disclosure. + */ case CONTEXT = 'Context'; - case EVENT_CAPTURE_COVERAGE = 'Capture coverage and privacy'; - case EVENT_CAPTURE_GUIDANCE = 'Enable captureContext and set traceLimit (1-16) on the development Events ' - . 'collector to capture selected context and argument-free source traces. Existing snapshots cannot recover ' - . 'missing data. Listeners, their durations, and final propagation results are not captured.'; - case EVENT_CONTEXT_CAPTURED = 'Selected context at observation time'; - case EVENT_CONTEXT_FAILED = 'Context capture failed'; - case EVENT_CONTEXT_NOT_CAPTURED = 'Not captured (context capture is opt-in)'; - case EVENT_CONTEXT_UNSUPPORTED = 'No context extractor for this event type'; - case EVENT_FIRST_OBSERVATION = 'First observation'; - case EVENT_GROUP_BY_EVENT = 'By event (whole capture)'; - case EVENT_GROUP_BY_SOURCE = 'By source (whole capture)'; - case EVENT_INSPECTION_GUIDANCE = 'Open an event for diagnostics. Times and observation numbers refer to the ' - . 'original capture, regardless of sorting or filtering.'; - case EVENT_TIMING_GUIDANCE = 'Offsets are relative to the first captured event. Gaps are not listener durations. ' - . 'Paired lifecycle intervals include nested work and dispatch overhead. A leave marker does not prove success.'; - case EVENT_TRACE_CAPTURED = 'Argument-free source trace'; - case EVENT_TRACE_FAILED = 'Source trace capture failed'; - case EVENT_TRACE_NOT_CAPTURED = 'Not captured (source trace capture is opt-in)'; - case EVENT_UNMATCHED_ENTRY = 'No matching entry captured'; + + /** + * Summary of the group filter disclosure above a panel table. + */ case GROUP_FILTERS = 'Group filters'; + + /** + * Heading of the source trace section in a detail disclosure. + */ case SOURCE_TRACE = 'Source trace'; } diff --git a/src/Panel/Profile/ProfileMessage.php b/src/Panel/Profile/ProfileMessage.php new file mode 100644 index 0000000..9b73045 --- /dev/null +++ b/src/Panel/Profile/ProfileMessage.php @@ -0,0 +1,58 @@ +begin('my-token');\n// …work…\n\$profiler->end('my-token');"; + + /** + * Headline of the empty state when the request captured no profiling span. + */ + case EMPTY_HEADLINE = 'No profiling data captured'; + + /** + * Explanation of the no-match state, offering the filter reset. + */ + case NO_MATCH_EXPLANATION = 'Adjust or clear the filters to show the captured spans.'; + + /** + * Headline of the no-match state when the active filters exclude every captured span. + */ + case NO_MATCH_HEADLINE = 'No spans match the active filters'; + + /** + * Closing note of the timeline fallback, pointing at the profiling details below the chart. + */ + case TIMELINE_UNAVAILABLE_DETAILS = 'The profiling details remain available below.'; + + /** + * Explanation of the timeline fallback, naming the capture values the chart requires. + */ + case TIMELINE_UNAVAILABLE_EXPLANATION = 'This capture does not contain the valid request start, ' + . 'duration, and peak-memory values required to position the chart.'; + + /** + * Headline of the timeline fallback when the capture cannot position the chart. + */ + case TIMELINE_UNAVAILABLE_HEADLINE = 'Timeline unavailable'; +} diff --git a/src/Panel/Profile/ProfileTimings.php b/src/Panel/Profile/ProfileTimings.php index f337a95..5729f1a 100644 --- a/src/Panel/Profile/ProfileTimings.php +++ b/src/Panel/Profile/ProfileTimings.php @@ -13,7 +13,7 @@ /** * Pairs profile begin/end log tuples into per-block timings. * - * @phpstan-import-type LogMessage from \PHPForge\Debug\Panel\Log\LogSnapshot + * @phpstan-import-type LogTuple from \PHPForge\Debug\Panel\Log\LogSnapshot * @phpstan-type ProfileTiming array{ * info: string, * category: string, @@ -33,14 +33,14 @@ final class ProfileTimings * Each tuple is `[token, level, category, timestamp, traces, memory]`; a begin marker is matched with the next end * marker carrying the same token, producing one timing entry ordered by the begin position. * - * @param list $messages Profile log tuples in capture order. + * @param list $messages Profile log tuples in capture order. * * @return list Timings ordered by their begin marker. */ public static function calculate(array $messages): array { $timings = []; - /** @var array> $stack */ + /** @var array> $stack */ $stack = []; $nestedLevel = 0; diff --git a/src/Panel/Profile/ProfilingSnapshot.php b/src/Panel/Profile/ProfilingSnapshot.php index bdbe639..4440b5b 100644 --- a/src/Panel/Profile/ProfilingSnapshot.php +++ b/src/Panel/Profile/ProfilingSnapshot.php @@ -18,7 +18,7 @@ * Canonical profiling snapshot holding the request metrics, the resolved profile blocks, and the memory samples that * feed the timeline chart. * - * @phpstan-import-type LogMessage from \PHPForge\Debug\Panel\Log\LogSnapshot + * @phpstan-import-type LogTuple from \PHPForge\Debug\Panel\Log\LogSnapshot */ final readonly class ProfilingSnapshot implements PanelSnapshot { @@ -36,7 +36,7 @@ public function __construct( /** * Resolves the logger's begin/end pairs into typed blocks and collects the per-message memory samples. * - * @param list $messages Profile tuples in capture order. + * @param list $messages Profile tuples in capture order. */ public static function capture(int $memory, float $time, array $messages): self { diff --git a/tests/Data/PageSizeTest.php b/tests/Data/PageSizeTest.php index d383378..8d0d7b5 100644 --- a/tests/Data/PageSizeTest.php +++ b/tests/Data/PageSizeTest.php @@ -99,6 +99,20 @@ public function testResolveReturnsNullForTheAllKeyword(): void ); } + public function testSelectorForReadsThePageSizeFromTheQuery(): void + { + self::assertSame( + PageSize::selectorHtml(PageSize::current('25')), + PageSize::selectorFor(['per-page' => '25']), + 'The query value must drive the selected option.', + ); + self::assertSame( + PageSize::selectorHtml(PageSize::current(null)), + PageSize::selectorFor([]), + 'A missing parameter must fall back to the default selector.', + ); + } + public function testSelectorHtmlMarksTheCurrentOptionSelected(): void { self::assertSame( diff --git a/tests/Helper/FormatTest.php b/tests/Helper/FormatTest.php index eae7fa6..85482c5 100644 --- a/tests/Helper/FormatTest.php +++ b/tests/Helper/FormatTest.php @@ -7,9 +7,11 @@ use PHPForge\Debug\Helper\Format; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use stdClass; /** - * Unit tests for {@see Format} covering the megabyte readout and the trimmed CSS percentage formatter. + * Unit tests for {@see Format} covering the megabyte readout, the trimmed CSS percentage formatter, and the value + * type labels. */ #[Group('helpers')] #[Group('format')] @@ -48,4 +50,53 @@ public function testCssPercentTrimsTrailingZerosAndDot(): void ); } } + + public function testTypeOfLabelsScalarsArraysStringsAndNull(): void + { + self::assertSame( + 'array(3)', + Format::typeOf([1, 2, 3]), + 'Arrays must report their element count.', + ); + self::assertSame( + 'array(0)', + Format::typeOf([]), + 'Empty arrays must report a zero count.', + ); + self::assertSame( + 'string(16)', + Format::typeOf('Test application'), + 'Strings must report their byte length.', + ); + self::assertSame( + 'string(0)', + Format::typeOf(''), + 'Empty strings must report a zero length.', + ); + self::assertSame( + 'int', + Format::typeOf(42), + "Integers must be labeled 'int'." + ); + self::assertSame( + 'float', + Format::typeOf(1.5), + "Floats must be labeled 'float'.", + ); + self::assertSame( + 'bool', + Format::typeOf(true), + "Booleans must be labeled 'bool'." + ); + self::assertSame( + 'null', + Format::typeOf(null), + "'null' must be labeled 'null'." + ); + self::assertSame( + 'object', + Format::typeOf(new stdClass()), + 'Unlisted types must fall back to the native type name.', + ); + } } diff --git a/tests/Panel/Event/EventMessageTest.php b/tests/Panel/Event/EventMessageTest.php new file mode 100644 index 0000000..8e68515 --- /dev/null +++ b/tests/Panel/Event/EventMessageTest.php @@ -0,0 +1,74 @@ +content(EventMessage::EMPTY_HEADLINE, ':