From b091563f0b0fefade09ef61c5986f9babb7c0d73 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 8 Sep 2026 08:10:07 -0300 Subject: [PATCH 1/3] refactor!: centralize fluent factories, shared UI helpers, payload hydration, formatting, and the reduced panel URL contract. --- .gitignore | 3 + CHANGELOG.md | 1 + README.md | 24 +- docs/browser-quality.md | 221 ------------- docs/ui-parity-baseline.md | 307 ------------------ package.json | 4 +- resources/views/snapshot.php | 42 ++- src/Capture/CapturePolicy.php | 5 +- src/Comparison/PanelComparison.php | 8 + src/Comparison/SnapshotComparison.php | 64 ++++ src/Comparison/SummaryMetricComparison.php | 8 + src/Data/FilterPrefix.php | 33 ++ src/Data/PageSize.php | 4 +- src/Data/QueryInput.php | 21 ++ src/Helper/Avatar.php | 22 +- src/Helper/Badge.php | 31 ++ src/Helper/ExtensionPill.php | 48 +++ src/Helper/Format.php | 83 ++++- src/Helper/SensitiveDataRedactor.php | 42 ++- src/Helper/Table.php | 72 ++++ src/Panel/Asset/AssetCardRenderer.php | 41 +-- src/Panel/Asset/AssetSectionRenderer.php | 2 +- src/Panel/Config/ConfigCardRenderer.php | 17 +- src/Panel/Db/DbExplainRenderer.php | 26 +- src/Panel/Db/DbQueryRenderer.php | 79 +++-- src/Panel/Db/DbSnapshot.php | 15 +- src/Panel/Db/NPlusOneFinding.php | 5 - src/Panel/Dump/DumpCardRenderer.php | 19 +- src/Panel/Dump/DumpSnapshot.php | 15 +- src/Panel/Event/EventCellRenderer.php | 16 +- src/Panel/Event/EventSnapshot.php | 15 +- src/Panel/Log/LogCellRenderer.php | 10 +- src/Panel/Log/LogSnapshot.php | 17 +- src/Panel/Mail/MailCardRenderer.php | 60 ++-- src/Panel/Mail/MailSnapshot.php | 13 +- src/Panel/PanelRenderContext.php | 21 -- src/Panel/Profile/ProfileCellRenderer.php | 15 +- src/Panel/Queue/QueueCardRenderer.php | 52 +-- src/Panel/Queue/QueueGridRenderer.php | 29 +- src/Panel/Queue/QueuePill.php | 49 +++ src/Panel/Queue/QueueSnapshot.php | 15 +- src/Panel/Request/RequestDiagnosticLedger.php | 52 +++ src/Panel/Request/RequestHeadersRenderer.php | 23 +- src/Panel/Request/RequestRenderer.php | 24 +- src/Panel/Request/RequestRoutesRenderer.php | 89 ++--- src/Panel/Request/RequestSectionRenderer.php | 61 ++-- src/Panel/Request/RequestServerRenderer.php | 48 +-- .../Request/RequestToolbarItemFactory.php | 20 +- .../Request/Routing/RouteInventoryView.php | 35 +- src/Panel/Router/CurrentRouteLogRow.php | 3 +- src/Panel/Router/RouterSectionRenderer.php | 125 +------ src/Panel/Timeline/TimelineMemoryRenderer.php | 31 +- src/Panel/User/UserDataNormalizer.php | 30 +- src/Panel/User/UserRbacRow.php | 15 +- src/Panel/Vite/ViteComponent.php | 37 +-- src/Panel/Vite/ViteSectionRenderer.php | 72 ++-- src/PhpInfo/PhpInfoRenderer.php | 21 +- src/Routing/DebugUrlGeneratorInterface.php | 18 +- src/Storage/DebugArray.php | 6 - src/Storage/DebugValue.php | 280 ++++------------ src/Storage/ExceptionSnapshot.php | 39 +-- src/Storage/Payload.php | 49 +++ src/Storage/RequestSummary.php | 2 +- src/Storage/SnapshotStore.php | 111 ++++--- src/Toolbar/ToolbarData.php | 6 - src/Toolbar/ToolbarInjector.php | 31 ++ src/Toolbar/ToolbarItem.php | 2 +- src/Toolbar/ToolbarPanel.php | 2 +- src/View/History/HistoryCellRenderer.php | 3 +- src/View/Sidebar/SidebarSnapshot.php | 2 +- tests/Collector/CollectorCoordinatorTest.php | 24 +- tests/Comparison/PanelComparisonTest.php | 11 +- tests/Comparison/PayloadDifferenceTest.php | 129 +------- tests/Comparison/SnapshotComparisonTest.php | 133 ++++++++ .../SummaryMetricComparisonTest.php | 29 ++ tests/Data/PageSizeTest.php | 37 ++- tests/Data/QueryInputTest.php | 43 ++- tests/Helper/AvatarTest.php | 30 +- tests/Helper/BadgeTest.php | 39 +++ tests/Helper/ExtensionPillTest.php | 39 +++ tests/Helper/FormatTest.php | 109 ++++++- tests/Helper/TableTest.php | 156 +++++++++ tests/Panel/Asset/AssetCardRendererTest.php | 101 +++--- tests/Panel/Db/DbExplainRendererTest.php | 37 +++ tests/Panel/Db/DbQueryRendererTest.php | 61 ++-- tests/Panel/Db/NPlusOneDetectorTest.php | 4 - tests/Panel/Event/EventMessageTest.php | 55 ++-- tests/Panel/Inertia/InertiaMessageTest.php | 55 ++-- tests/Panel/Log/LogMessageTest.php | 55 ++-- tests/Panel/PanelMessageTest.php | 57 ++-- tests/Panel/PanelRenderContextTest.php | 42 +-- tests/Panel/Profile/ProfileMessageTest.php | 55 ++-- tests/Panel/Queue/QueueCardRendererTest.php | 114 ++----- tests/Panel/Queue/QueueGridRendererTest.php | 84 ++--- .../Request/RequestDataNormalizerTest.php | 35 +- .../Request/RequestDiagnosticLedgerTest.php | 73 +++++ .../Request/RequestHeadersRendererTest.php | 10 + tests/Panel/Request/RequestRendererTest.php | 63 +++- .../Request/RequestRoutingViewModelsTest.php | 66 ++-- tests/Panel/Vite/ViteSectionRendererTest.php | 150 +++++++-- tests/Provider/PayloadDifferenceProvider.php | 135 ++++++++ tests/Provider/ToolbarPanelProvider.php | 25 ++ tests/Storage/DebugSnapshotTest.php | 18 +- tests/Storage/ManifestTest.php | 18 +- tests/Storage/PayloadTest.php | 33 ++ tests/Storage/RequestSummaryTest.php | 99 +++--- tests/Storage/SnapshotStoreTest.php | 23 +- tests/Support/JobRecordFixture.php | 56 ++++ tests/Support/MessageCatalogTestCase.php | 79 +++++ tests/Support/RequestSummaryFixture.php | 52 +++ tests/Toolbar/ToolbarDataTest.php | 76 ++--- tests/Toolbar/ToolbarInjectorTest.php | 57 ++++ tests/Toolbar/ToolbarItemTest.php | 59 +++- tests/Toolbar/ToolbarPanelTest.php | 82 +++-- .../View/History/HistoryCellRendererTest.php | 73 +++-- tests/View/History/HistoryRowTest.php | 32 +- tests/View/History/HistoryScaleTest.php | 15 +- tests/View/History/HistorySummaryTest.php | 18 +- tests/View/Sidebar/SidebarSnapshotTest.php | 87 +++++ 119 files changed, 3107 insertions(+), 2637 deletions(-) delete mode 100644 docs/browser-quality.md delete mode 100644 docs/ui-parity-baseline.md create mode 100644 src/Comparison/SnapshotComparison.php create mode 100644 src/Helper/Badge.php create mode 100644 src/Helper/ExtensionPill.php create mode 100644 src/Helper/Table.php create mode 100644 src/Panel/Queue/QueuePill.php create mode 100644 src/Panel/Request/RequestDiagnosticLedger.php create mode 100644 src/Toolbar/ToolbarInjector.php create mode 100644 tests/Comparison/SnapshotComparisonTest.php create mode 100644 tests/Helper/BadgeTest.php create mode 100644 tests/Helper/ExtensionPillTest.php create mode 100644 tests/Helper/TableTest.php create mode 100644 tests/Panel/Request/RequestDiagnosticLedgerTest.php create mode 100644 tests/Provider/PayloadDifferenceProvider.php create mode 100644 tests/Provider/ToolbarPanelProvider.php create mode 100644 tests/Support/JobRecordFixture.php create mode 100644 tests/Support/MessageCatalogTestCase.php create mode 100644 tests/Support/RequestSummaryFixture.php create mode 100644 tests/Toolbar/ToolbarInjectorTest.php create mode 100644 tests/View/Sidebar/SidebarSnapshotTest.php diff --git a/.gitignore b/.gitignore index 310dc96..c6f7441 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ composer.lock .github/prompts/** .github/skills/** +# Internal docs +internal + # mac ds_store (if present) .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ae18de..66e770b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,3 +46,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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(). +- refactor!: centralize fluent factories, shared UI helpers, payload hydration, formatting, and the reduced panel URL contract. diff --git a/README.md b/README.md index 86eb647..2a47322 100644 --- a/README.md +++ b/README.md @@ -23,19 +23,18 @@ does not register assets, render responses, inject toolbar markup, or depend on view implementation, or a framework request lifecycle. Shared adapter UI contracts include `PHPForge\Debug\Data\FilterEngine`, `FilterPrefix`, `PageSize`, and `QueryInput`, -plus `PHPForge\Debug\Panel\PanelRenderContext`. Adapters provide a -`PHPForge\Debug\Routing\DebugUrlGeneratorInterface` implementation so portable panel renderers can build history, -panel, and action links without importing a framework URL manager. +plus `PHPForge\Debug\Panel\PanelRenderContext`. `PHPForge\Debug\Comparison\SnapshotComparison` compares two captures +(summary metrics and panel payloads) for the history comparison pages, and `PHPForge\Debug\Toolbar\ToolbarInjector` +places the rendered toolbar before ``. Adapters provide a +`PHPForge\Debug\Routing\DebugUrlGeneratorInterface` implementation so portable panel renderers can build panel +links without importing a framework URL manager. Adapters collect framework data, convert it into immutable snapshots, expose toolbar data endpoints, define and publish assets through their framework, and render the shared templates with their framework view component. They also -own toolbar response injection. Routes, controllers or actions, URL generation, panel metadata, and framework-specific +decide when a response receives the toolbar. Routes, controllers or actions, URL generation, panel metadata, and framework-specific panel views remain in each adapter. Yii adapters resolve the packaged frontend at `@vendor/php-forge/debug-core/resources/assets` and configure their own alias for `resources/views`. -The visual and behavioral synchronization contract for the Yii adapters is documented in the -[Yii Debug UI parity baseline](docs/ui-parity-baseline.md). - Persistent adapters apply `PHPForge\Debug\Capture\CapturePolicy` before snapshot capture. Its secure defaults redact common credentials, authorization and cookie values recursively, suppress raw bodies whose decoded form changed, truncate opaque bodies at 64 KiB, and sanitize query strings and diagnostic assignments. Tagged-value capture and @@ -54,8 +53,7 @@ Current adapters: Request models keep only identity data in their constructors and `::create()` factories. Use the factories to start fluent chains without wrapping `new` in parentheses. Optional metadata is configured with `with...` methods -that return independent copies; retain the returned object or chain the calls. Read values through `get...` methods -and use `RouteInventoryView::isLive()` for inventory provenance. +that return independent copies; retain the returned object or chain the calls. Read values through `get...` methods. ```php use PHPForge\Debug\Panel\Request\RequestHero; @@ -68,8 +66,7 @@ $current = CurrentRouteView::create('orders') ->withDefinition($definition) ->withParameters(['id' => 42]); $inventory = RouteInventoryView::create([$definition]) - ->withSource('Captured configuration') - ->withLive(false); + ->withSource('Captured configuration'); $hero = RequestHero::create('GET', '/orders/42') ->withStatus(200, '2xx') ->withTiming('12:00:00', '3.5 ms'); @@ -102,8 +99,8 @@ The package is released under the BSD-3-Clause license. See `LICENSE`. ## Fluent toolbar models -`ToolbarItem::create($value)` and `ToolbarPanel::create($id, $title)` start immutable configuration chains. -Their existing constructors and public readonly properties remain supported, including named arguments. +`ToolbarItem::create($value)` and `ToolbarPanel::create($id, $title)` start immutable configuration chains and are the +only construction path: the constructors are private, and the public readonly properties remain readable. ```php use PHPForge\Debug\Toolbar\{ToolbarItem, ToolbarPanel}; @@ -134,7 +131,6 @@ distinct. Leaf paths escape `~` and `/`; list positions matter, while map insert The comparison fingerprints typed leaves temporarily and retains only counts in its result. It does not alter or redact the source payloads. `PanelComparison` combines these counts with capture states and ordered panel identities. -See the [architecture review](docs/architecture-review.md) for boundaries and follow-up work. ## Panel comparison diff --git a/docs/browser-quality.md b/docs/browser-quality.md deleted file mode 100644 index 0ffd13b..0000000 --- a/docs/browser-quality.md +++ /dev/null @@ -1,221 +0,0 @@ -# Browser quality workflow - -## Prerequisites - -- Node.js from `.nvmrc` and the locked npm dependencies. -- PHP and Composer dependencies for Debug Core, `../../app-vue`, and - `../../app-react`. -- The local applications at their contract paths, or two compatible origins - supplied through environment variables. -- A Playwright Chromium installation or a compatible system Chromium binary. - -Install the browser managed by Playwright: - -```shell -npm ci -npx playwright install --with-deps chromium -``` - -For a preinstalled browser, set its absolute path instead: - -```shell -export PLAYWRIGHT_CHROMIUM_EXECUTABLE=/usr/bin/chromium -``` - -The custom-executable mode disables Playwright video recording because its -managed FFmpeg companion is not guaranteed to be installed. Failure screenshots, -traces, diagnostics, and all explicit visual-atlas images remain available. - -## Seed deterministic snapshots - -```shell -npm run fixtures:seed -``` - -The command upserts the dense and empty quality tags into both applications and -validates the synthetic redaction sentinel after persistence. Override the two -application paths in contract order when necessary: - -```shell -php tools/seed-debug-fixtures.php \ - --app=/workspace/app-vue \ - --app=/workspace/app-react \ - --rows=80 -``` - -`--rows` accepts 1 through 5,000. Use the default for the full visual suite; use -the dedicated offline performance harness for repeatable high-row-count checks. -The dense Database fixture targets the shared local `user` schema with -plan-valid SELECT, INSERT, UPDATE, and DELETE statements. Its DML predicates are -deliberate no-ops, and the browser smoke workflow opens each verb before running -Explain All, rejecting any inline request failure, and verifying that each local -SQLite database remains byte-for-byte unchanged. - -## Start the applications - -Playwright can own both PHP development servers: - -```shell -DEBUG_UI_START_SERVERS=1 npm run test:e2e -``` - -This starts: - -```text -app-vue http://localhost:8080 -app-react http://localhost:8081 -``` - -Alternatively, start the applications in separate terminals and omit -`DEBUG_UI_START_SERVERS`. Existing compatible servers are reused. - -To inspect already-running remote or containerized origins, keep the order stable: - -```shell -DEBUG_UI_BASE_URLS=http://127.0.0.1:9080,http://127.0.0.1:9081 \ -DEBUG_UI_APP_NAMES=vue-container,react-container \ -npm run test:e2e -``` - -Only HTTP(S) origins are accepted. Fixture seeding still uses the local contract -paths unless it is disabled with `DEBUG_UI_SEED_FIXTURES=0`. - -## Commands - -```shell -# Smoke, keyboard, traversal, runtime diagnostics, and the privacy sentinel. -npm run test:e2e - -# WCAG-tagged axe scans of the document and toolbar shadow DOM. -npm run test:a11y - -# Reproducible PNG atlas without golden comparison. -npm run test:visual - -# Compare with existing Playwright screenshot baselines. -npm run test:visual:compare - -# Explicitly create or update screenshot baselines. -npm run test:visual:update - -# Offline 50/1,000/5,000-row browser benchmark. -npm run test:perf - -# Dense live-panel navigation and DOM envelope. -npm run test:perf:live - -# Distribution and design-token budgets. -npm run check:size -npm run check:contrast -npm run check:contrast:strict - -# Discovery-only configuration validation; no applications or browser required. -npm run test:e2e:list -``` - -Select a project or test while iterating: - -```shell -DEBUG_UI_START_SERVERS=1 npm run test:e2e -- --project=desktop-1440 -DEBUG_UI_START_SERVERS=1 npm run test:a11y -- --project=mobile-390 --grep='light dense panels' -DEBUG_UI_START_SERVERS=1 npm run test:visual -- --project=tablet-1024 --grep='db is stable' -``` - -## Artifacts and screenshot names - -Generated outputs are local and ignored by Git: - -```text -artifacts/playwright/report/ -artifacts/playwright/results/ -artifacts/ui//--.png -artifacts/ui/desktop-1440/-light-empty-.png -``` - -Open `artifacts/playwright/report/index.html` after a failure to inspect traces, -attachments, axe JSON, performance JSON, videos, and failure screenshots. - -Golden screenshots, when explicitly enabled, use: - -```text -e2e/snapshots//.png -``` - -Do not update goldens as a way to silence an unexplained difference. Compare the -Vue and React pair, inspect all themes and breakpoints affected by the change, -then record the intended visual change in the project changelog. - -## Reproducibility controls - -The Playwright configuration fixes locale to `en-US`, time zone to UTC, reduced -motion, explicit viewport sizes, and the default light color scheme. The fixtures -use stable tags, timestamps, ordering, row counts, paths, and synthetic data. -Visual capture waits for document fonts, disables animations and transitions, -and waits for two animation frames. - -The screenshot environment is still platform-sensitive because font rasterization -and browser versions differ. Generate and compare goldens with the same OS, -Chromium build, device scale factor, and dependency lock. Artifact-only capture is -the safer default for local design review. - -## Size and contrast maintenance - -`tools/check-asset-size.mjs` scans every file under `resources/assets/dist`, -compresses it with gzip level 9, and fails for missing, unexpected, per-file, or -aggregate budget violations. Update `tools/quality/asset-size-budget.json` only -after rebuilding assets and recording a justified before/after measurement. - -`tools/check-token-contrast.mjs` parses hex `light-dark()` token pairs and `var()` -aliases directly from `resources/src/styles/tokens.css`. It uses WCAG relative -luminance against the canonical light `#ffffff` and dark `#121a15` surfaces. It -also checks the actual semantic ink/background token compositions used by solid -success, warning, danger, and information badges. `npm run check:contrast` -permits only explicitly documented baseline floors; none are configured in the -current baseline. `npm run check:contrast:strict` requires every checked token to -meet its role's target and must remain green. Axe independently verifies the -browser-computed composition. - -## CI boundary - -The asset workflow performs formatting, JavaScript/style tests, a clean Vite -build, size and contrast enforcement, Playwright discovery, and the offline -large-data browser harness. It does not run the live cross-adapter matrix because -the two sibling applications and their path Composer repositories are not part of -the Debug Core checkout. - -A future mandatory visual CI job should version the demo applications, pin PHP, -Chromium, fonts, Node.js, locale, and OS image, seed the same contract, retain -private artifacts, and review baseline changes explicitly. - -## Privacy cautions - -The deterministic panels are synthetic, but the application root, incidental -developer captures, PHP Info, trace files, and videos can reveal local paths or -environment information. Keep browser artifacts private. Do not point the suite -at a production debugger, and never put real secrets into the fixture contract. - -## Troubleshooting - -### A port is already in use - -Either stop the unrelated process, run compatible existing applications on the -contract ports, or use `DEBUG_UI_BASE_URLS` with two alternate origins. Do not swap -the Vue and React order. - -### The fixture generator cannot find an application - -Verify `../../app-vue` and `../../app-react`, or pass two `--app` overrides in -that order. Each path must contain `composer.json`, `vendor/`, and `runtime/`. - -### Playwright cannot launch Chromium - -Run `npx playwright install chromium`, or set -`PLAYWRIGHT_CHROMIUM_EXECUTABLE` to a compatible executable. Remove the override -when comparing Playwright-managed screenshot baselines. - -### A test reports runtime diagnostics - -Treat document, script, stylesheet, font, console, and page errors as product -failures. Inspect the trace and network log before changing the assertion. -Aborted requests caused by navigation are ignored; other critical failures are -not. diff --git a/docs/ui-parity-baseline.md b/docs/ui-parity-baseline.md deleted file mode 100644 index 8819912..0000000 --- a/docs/ui-parity-baseline.md +++ /dev/null @@ -1,307 +0,0 @@ -# Yii Debug UI parity baseline - -## Purpose - -This document is the review contract for modernizing the shared Debug Core UI -without allowing the Yii adapter implementations to drift. It defines the -surfaces, fixture states, viewports, themes, interaction rules, accessibility -checks, privacy boundary, and reproducible artifacts that must remain covered. -See [Browser quality workflow](browser-quality.md) for setup and command details. - -The baseline is intentionally framework-neutral. The same packaged CSS, -JavaScript, fonts, PHP view models, and renderers are exercised through the two -local adapter applications: - -| Adapter fixture | Application | Origin | -| --------------- | ----------------- | ----------------------- | -| Vue client | `../../app-vue` | `http://localhost:8080` | -| React client | `../../app-react` | `http://localhost:8081` | - -The port assignment is part of -`tools/quality/fixture-contract.json`. Do not infer it from the client name or -swap it in local scripts. - -## Deterministic capture states - -`php tools/seed-debug-fixtures.php` upserts two storage-version 4 snapshots into -each application's `runtime/debug` store. Existing developer captures are not -deleted. - -| State | Stable tag | Intent | -| ----- | -------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| Empty | `quality-fixture-empty-v1` | Valid zero-row and optional-data states for every panel | -| Dense | `quality-fixture-dense-v1` | Representative content, 80 rows in data-heavy panels, long text, Unicode, traces, successes, warnings, and failures | - -The generator hydrates and serializes all panel payloads through the production -snapshot DTOs before writing them through `SnapshotStore`. Schema drift therefore -fails setup instead of silently producing a misleading screenshot. - -The dense fixture also contains an end-to-end privacy sentinel. A synthetic value -under the default exact key `password` and another under the configured prefix -`fixture_blocked_` must be replaced by `[redacted]`. A non-sensitive control value -must remain. The seeder inspects the persisted JSON after the write, and -`e2e/security.spec.js` inspects both the stored tagged-value envelope and rendered -DOM. The sentinel strings are test markers, not real credentials. - -The fixed capture timestamps begin at `2026-08-24T00:00:00Z`. The history query -uses `Debug[tag]=quality-fixture` so unrelated local requests do not affect row -counts or screenshots. - -## Surface inventory - -The browser suite treats the following 17 document surfaces as the complete UI -inventory. A new panel is incomplete until it is added to this table, -`e2e/support/environment.js`, the fixture generator, accessibility traversal, -and the visual atlas. - -| Surface | Route kind | Dense-state emphasis | Empty-state expectation | -| ------------------- | ----------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| Request history | `/debug/index` | Two deterministic captures and summary metrics | Filtered history remains understandable with no unrelated captures | -| Snapshot comparison | `/debug/compare` | Empty baseline versus dense target across all 14 panels | Missing and unchanged values remain distinct from changes | -| PHP Info | `/debug/php-info` | Standalone environment table and navigation shell | Not fixture-backed; layout must remain bounded | -| Configuration | `panel=config` | Application, PHP, and extension metadata | Sections render without malformed placeholders | -| Request | `panel=request` | Hero, parameters, body, headers, session, and server tabs | Valid empty collections and 204 status | -| Router | `panel=router` | Matching rule and rule trace | No route or rule entries | -| Inertia | `panel=inertia` | Component, props, headers, and shared data | No component or props | -| User | `panel=user` | Identity, attributes, roles, and permissions | Guest/no identity | -| Logs | `panel=log` | 80 mixed-level messages and long diagnostics | Empty table state | -| Database | `panel=db` | 80 plan-valid mixed query types, durations, duplicates, and traces | Empty table state | -| Profiling | `panel=profiling` | 80 entries, memory samples, and categories | Summary with no entries | -| Timeline | `panel=timeline` | Duration and memory overview | Minimal valid interval | -| Events | `panel=event` | 80 static and instance events | Empty table state | -| Mail | `panel=mail` | 16 success/failure messages and metadata | Empty table state | -| Queue | `panel=queue` | Push, execute, and error events | Empty table state | -| Dump | `panel=dump` | 24 traced values | Empty table state | -| Asset Bundles | `panel=asset` | Bundles plus Vite manifest chunks | No bundles and no Vite data | - -## URL contract - -Examples for the Vue application are shown below; replace only the origin for -the React application. - -```text -http://localhost:8080/debug/index?yii_debug_theme=light&Debug%5Btag%5D=quality-fixture -http://localhost:8080/debug/compare?yii_debug_theme=light&baseline=quality-fixture-empty-v1&target=quality-fixture-dense-v1 -http://localhost:8080/debug/php-info?yii_debug_theme=dark -http://localhost:8080/debug/view?yii_debug_theme=light&tag=quality-fixture-dense-v1&panel=request -http://localhost:8080/debug/view?yii_debug_theme=dark&tag=quality-fixture-empty-v1&panel=db -``` - -The toolbar is exercised on the application root. Drawer URLs must remain on the -same HTTP(S) origin and target a recognized debug route. - -## Theme and responsive matrix - -The dense visual atlas covers every surface in both explicit themes and all -three review viewports: - -| Playwright project | Viewport | Review intent | -| ------------------ | ---------- | ---------------------------------------------------------------- | -| `desktop-1440` | 1440 × 900 | Full navigation, dense tables, charts, and drawer | -| `tablet-1024` | 1024 × 768 | Intermediate wrapping and available table width | -| `mobile-390` | 390 × 844 | Single-column flow, touch-size controls, and horizontal overflow | - -The empty atlas is captured in light mode at 1440 px because the dense atlas -already covers theme and breakpoint behavior. Smoke and accessibility tests run -both fixture states at every viewport. - -The explicit theme contract is: - -- debugger documents set `data-yii-debug-theme="light|dark"` on ``; -- the toolbar sets `data-theme="light|dark"` on its Web Component host; -- light and dark tokens originate in `resources/src/styles/tokens.css`; -- a theme switch must update its label and selected theme without reloading the - inspected application. - -## Behavioral parity contract - -Both adapters must preserve the following observable behavior: - -1. All routes return a successful HTTP response and render `.yii-debug-page`. -2. When a panel is registered in the sidebar, exactly one link is active and it - targets the rendered panel. Configuration remains a header action, and a - directly addressable optional panel may be absent from adapter navigation. -3. Documents do not create page-level horizontal overflow at any review width. - A deliberately scrollable table wrapper is allowed. -4. The toolbar loads its data without console, page, script, stylesheet, or font - failures. -5. The expand and collapse controls expose their current action through an - accessible name. -6. A keyboard-activated toolbar chip opens the drawer and moves focus to the - close control. -7. `Escape` closes the drawer and restores focus to the activating chip. -8. `ArrowUp`, `ArrowDown`, `Home`, and `End` operate on the drawer resize - separator while its ARIA value reflects the resulting height. -9. Repeated toolbar data refreshes must not discard an open iframe's document, - focus, or scroll position. -10. Runtime diagnostics are empty after each traversal step. - -Framework-specific data may differ for developer-generated captures. Fixture -screenshots, structure, spacing, typography, controls, focus behavior, and empty -states must not differ merely because the host page uses Vue or React. - -## Visual identity baseline - -The current design direction is an engineering instrument rather than a generic -administration dashboard: - -- IBM Plex Sans for interface text and JetBrains Mono for code, values, and the - display accent; -- viridian/emerald primary actions over green-tinted neutral surfaces; -- compact cards, metrics, pills, tables, tab strips, trace disclosures, and - semantic colors shared by every panel; -- explicit color vocabularies for HTTP verbs and status classes, log levels, SQL - tokens, and timeline categories; -- one focus-ring token and a reduced-motion-compatible interaction layer; -- one toolbar implementation isolated in shadow DOM but driven by the same - tokens. - -A modernization may refine hierarchy, density, responsive navigation, and data -visualization. It should not introduce panel-specific typography or unrelated -color systems. - -## Automated acceptance gates - -| Gate | Command | Pass condition | -| ------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------- | -| Fixture schema and persisted sentinel | `npm run fixtures:seed` | Both stable tags are written; forbidden sentinels are absent; exact and prefixed values are placeholders | -| Smoke and keyboard | `npm run test:e2e` | Both apps, 14 panels, dense/empty, history, comparison, PHP Info, toolbar, and privacy checks pass | -| Accessibility | `npm run test:a11y` | No serious or critical axe WCAG A/AA violations and no runtime diagnostics | -| Reproducible visual atlas | `npm run test:visual` | PNG artifacts are produced under `artifacts/ui//` with stable names | -| Optional golden comparison | `npm run test:visual:compare` | Opted-in screenshots match reviewed baselines | -| Offline large-data harness | `npm run test:perf` | 50, 1,000, and 5,000 row render/filter/DOM budgets pass | -| Live panel envelope | `npm run test:perf:live` | Dense panel navigation timing and DOM-node budgets pass | -| Asset size | `npm run check:size` | Every expected distribution asset and aggregate raw/gzip size is within budget | -| Token contrast | `npm run check:contrast:strict` | Every configured text, non-text, and semantic badge composition meets its WCAG role target | - -The default visual run writes review artifacts but does not force binary goldens -into version control. Setting `DEBUG_UI_VISUAL_COMPARE=1` enables Playwright's -pixel comparison. `npm run test:visual:update` is an explicit baseline-update -operation and must be followed by paired Vue/React review. - -The dense atlas produces 204 document screenshots (2 apps × 2 themes × 3 -viewports × 17 surfaces), 12 toolbar-drawer screenshots, and 28 desktop empty -panel screenshots: 244 review images in total. - -## Accessibility baseline - -`e2e/accessibility.spec.js` scans the debugger document and toolbar shadow DOM -with axe rules tagged WCAG 2.0 A/AA, WCAG 2.1 A/AA, and WCAG 2.2 AA. Serious and -critical violations fail immediately; full JSON results are attached to the -Playwright report for review. - -This severity gate is a starting point rather than a claim of complete WCAG -conformance. Manual review is still required for: - -- meaningful reading and focus order at all widths; -- visible focus, target size, and zoom/reflow at 200% and 400%; -- chart and semantic-color comprehension without color alone; -- screen-reader announcements for dynamic filters, drawer loading, and errors; -- keyboard reachability of disclosures, tabs, filters, pagination, and copied - values. - -## Privacy and artifact handling - -Debug captures can contain request bodies, headers, cookies, SQL, mail, identity -data, filesystem paths, environment configuration, and PHP Info. The deterministic -fixtures use only synthetic values, but PHP Info and the inspected application -root still describe the local machine. - -- `artifacts/` is ignored by Git and is local-only by default. -- Do not upload Playwright traces, videos, reports, or PHP Info screenshots to a - public artifact store without a separate sanitization decision. -- Never replace the synthetic sentinel strings with working credentials. -- A UI must not recover, embed, or expose data that was redacted before storage. -- Modernization must preserve text escaping and safe URL handling; visual - convenience does not override those boundaries. - -## Performance and size baseline - -The packaged modernization baseline is 400,604 raw bytes and 204,510 bytes using -gzip level 9. Dynamically loaded database, DOM, PHP Info search, and user-switch -chunks are budgeted separately from the main debugger bundle. The file-specific ceilings in -`tools/quality/asset-size-budget.json` provide approximately five percent -headroom and reject missing or unexpected distribution files. Any budget -increase requires a before/after measurement and an explanation of user value. - -`tools/quality/performance-budget.json` establishes intentionally generous, -cross-machine guardrails. It is a regression sentinel, not a user-perceived -performance claim. Record median and tail behavior separately before tightening -budgets or selecting virtualization thresholds. - -## Known baseline limitations - -1. The required axe gate excludes moderate and minor findings so current debt can - be triaged without making the suite unusable. Each modernization phase should - reduce, not expand, the full attached violation set. -2. Goldens are opt-in. Core CI detects fixture, script, contrast, size, and - offline performance regressions, but it cannot run the local sibling demo - applications. A versioned demo environment is required before pixel diffs can - be a reliable mandatory CI gate. -3. Empty and dense captures do not yet cover every loading, storage-corruption, - transport-error, permission-denied, or very-long-localization state. Add - deterministic fixtures before redesigning those states. -4. The 5,000-row harness isolates table cost. Live fixtures are capped at 80 rows - to keep the complete visual matrix practical; large real panel datasets still - need profiling and, likely, pagination or virtualization. - -## Modernization sequence - -### Phase 0: protect contracts - -- Keep the deterministic tags, cross-adapter URL mapping, sentinel, asset budget, - and browser diagnostics green. -- Fix serious/critical accessibility failures and unsafe URL or escaping behavior - before visual expansion. -- Review paired Vue/React images for every changed shared primitive. - -### Phase 1: foundations - -- Keep the strict token contrast gate green, validate compound colors in the DOM - with axe, and document surface, border, text, semantic, spacing, radius, - elevation, and motion roles. -- Standardize focus, hover, active, disabled, selected, loading, empty, error, - and truncation states across controls. -- Establish responsive navigation behavior rather than relying on incidental - wrapping. - -### Phase 2: shared composition - -- Modernize shell hierarchy, navigation, page header, metric cards, filters, - tables, tabs, disclosures, pagination, copy actions, and drawer chrome as - shared patterns. -- Preserve one consistent, readable information density across panels. -- Ensure mobile views expose data through progressive disclosure instead of - document-level overflow. - -### Phase 3: data-heavy panels - -- Prioritize Logs, Database, Profiling, Timeline, Events, Mail, Queue, and Dump. -- Benchmark filter latency and DOM size with 1,000 and 5,000 rows before choosing - pagination, server filtering, windowing, or virtualization. -- Pair semantic color with labels, icons, patterns, or position. - -### Phase 4: resilience and polish - -- Add deterministic loading, stale-refresh, endpoint-error, corrupt-storage, - no-permission, long-value, and localization fixtures. -- Add screen-reader announcements and reduced-motion coverage for dynamic state. -- Promote reviewed screenshot baselines to CI only after the rendering environment - and demo applications are versioned and stable. - -## Review checklist - -- [ ] Dense and empty fixture tags exist in both stores. -- [ ] The Vue origin is 8080 and the React origin is 8081. -- [ ] All 14 panels plus History, Snapshot Comparison, and PHP Info were - inspected. -- [ ] Light and dark results were inspected at 1440, 1024, and 390 px. -- [ ] Toolbar expand, keyboard activation, resize, theme, refresh, close, and focus - restoration were inspected. -- [ ] Paired Vue/React screenshots differ only where host data legitimately differs. -- [ ] No console, page, script, stylesheet, or font errors occurred. -- [ ] Serious/critical axe output is empty and remaining findings are triaged. -- [ ] Synthetic sensitive sentinel values are absent from storage and DOM while - `[redacted]` is present. -- [ ] Asset and performance budgets did not regress without an approved rationale. -- [ ] Traces, videos, reports, and PHP Info images remain private unless sanitized. diff --git a/package.json b/package.json index 46b5224..ccf2e7c 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", - "format": "prettier --write 'resources/{src,tests}/**/*.js' 'e2e/**/*.js' 'tools/**/*.{mjs,json}' 'docs/**/*.md' 'playwright.config.js' 'vite.config.js' 'vitest.config.js' 'stryker.config.mjs' 'package.json'", - "format:check": "prettier --check 'resources/{src,tests}/**/*.js' 'e2e/**/*.js' 'tools/**/*.{mjs,json}' 'docs/**/*.md' 'playwright.config.js' 'vite.config.js' 'vitest.config.js' 'stryker.config.mjs' 'package.json'", + "format": "prettier --write 'resources/{src,tests}/**/*.js' 'e2e/**/*.js' 'tools/**/*.{mjs,json}' 'playwright.config.js' 'vite.config.js' 'vitest.config.js' 'stryker.config.mjs' 'package.json'", + "format:check": "prettier --check 'resources/{src,tests}/**/*.js' 'e2e/**/*.js' 'tools/**/*.{mjs,json}' 'playwright.config.js' 'vite.config.js' 'vitest.config.js' 'stryker.config.mjs' 'package.json'", "lint:css": "stylelint --config .github/linters/.stylelintrc.json --config-basedir . 'resources/src/**/*.css'", "lint:css:fix": "stylelint --config .github/linters/.stylelintrc.json --config-basedir . --fix 'resources/src/**/*.css'", "lint:js": "eslint --config .github/linters/eslint.config.mjs resources e2e tools playwright.config.js vitest.config.js", diff --git a/resources/views/snapshot.php b/resources/views/snapshot.php index 1d0142e..a3d0f38 100644 --- a/resources/views/snapshot.php +++ b/resources/views/snapshot.php @@ -16,30 +16,24 @@ * @var string|null $renderError Panel renderer error message or `null` when rendering succeeded. * @var string $url Captured request URL. */ +$dangerCallout = static fn(string $headline, string $detail): Div => Div::tag() + ->role('alert') + ->class('yii-debug-callout yii-debug-callout-danger') + ->html( + Div::tag()->html( + Strong::tag()->content($headline), + '
', + Span::tag()->content($detail), + ), + ); + $failureCallout = $failure === null ? '' - : Div::tag() - ->role('alert') - ->class('yii-debug-callout yii-debug-callout-danger') - ->html( - Div::tag()->html( - Strong::tag()->content('Panel ' . $failure['stage'] . ' failed.'), - '
', - Span::tag()->content($failure['exception']), - ), - ); + : $dangerCallout('Panel ' . $failure['stage'] . ' failed.', $failure['exception']); $renderCallout = $renderError === null ? '' - : Div::tag() - ->role('alert') - ->class('yii-debug-callout yii-debug-callout-danger') - ->html( - Div::tag()->html( - Strong::tag()->content('Panel rendering failed.'), - '
', - Span::tag()->content($renderError), - ), - ); + : $dangerCallout('Panel rendering failed.', $renderError); + $panelHeader = $panelContent !== null ? '' : Header::tag() @@ -48,8 +42,12 @@ Div::tag() ->class('yii-debug-panel-heading-copy') ->html( - Small::tag()->class('yii-debug-panel-heading-eyebrow')->content('Selected panel'), - H2::tag()->id('yii-debug-panel-title')->content($panelLabel), + Small::tag() + ->class('yii-debug-panel-heading-eyebrow') + ->content('Selected panel'), + H2::tag() + ->id('yii-debug-panel-title') + ->content($panelLabel), ), Span::tag() ->class('yii-debug-panel-heading-kind') diff --git a/src/Capture/CapturePolicy.php b/src/Capture/CapturePolicy.php index 721d35b..85a9259 100644 --- a/src/Capture/CapturePolicy.php +++ b/src/Capture/CapturePolicy.php @@ -55,10 +55,7 @@ public function __construct( ); } - $this->sensitiveKeyPatterns = $sensitiveKeyPatterns - ?? ($this->sensitiveKeys === SensitiveDataRedactor::DEFAULT_KEYS - ? SensitiveDataRedactor::DEFAULT_PATTERNS - : []); + $this->sensitiveKeyPatterns = SensitiveDataRedactor::patterns($this->sensitiveKeys, $sensitiveKeyPatterns); SensitiveDataRedactor::isSensitiveKey( '', diff --git a/src/Comparison/PanelComparison.php b/src/Comparison/PanelComparison.php index 7d7209b..baf2651 100644 --- a/src/Comparison/PanelComparison.php +++ b/src/Comparison/PanelComparison.php @@ -104,6 +104,14 @@ public static function between(DebugSnapshot $baseline, DebugSnapshot $target, a return $comparisons; } + /** + * Returns the total number of structural differences detected for the panel. + */ + public function differenceCount(): int + { + return $this->added + $this->removed + $this->changed; + } + /** * Returns the captured payload or failure envelope, preserving the distinction between absent and empty. * diff --git a/src/Comparison/SnapshotComparison.php b/src/Comparison/SnapshotComparison.php new file mode 100644 index 0000000..a665f63 --- /dev/null +++ b/src/Comparison/SnapshotComparison.php @@ -0,0 +1,64 @@ + $metrics Summary metric comparisons in canonical history order. + * @param list $panels Panel comparisons in configured display order. + */ + private function __construct( + public DebugSnapshot $baseline, + public DebugSnapshot $target, + public array $metrics, + public array $panels, + ) {} + + /** + * Compares two snapshots, combining summary metrics with panel structural differences. + * + * @param array $panelLabels Display names indexed by stable panel ID, in display order. + */ + public static function between(DebugSnapshot $baseline, DebugSnapshot $target, array $panelLabels = []): self + { + return new self( + baseline: $baseline, + target: $target, + metrics: SummaryMetricComparison::between($baseline->summary, $target->summary), + panels: PanelComparison::between($baseline, $target, $panelLabels), + ); + } + + /** + * Returns whether any summary metric or panel payload differs. + */ + public function hasDifferences(): bool + { + foreach ($this->metrics as $metric) { + if ($metric->hasDifference()) { + return true; + } + } + + foreach ($this->panels as $panel) { + if ($panel->differenceCount() > 0) { + return true; + } + } + + return false; + } +} diff --git a/src/Comparison/SummaryMetricComparison.php b/src/Comparison/SummaryMetricComparison.php index 86537f5..9e71473 100644 --- a/src/Comparison/SummaryMetricComparison.php +++ b/src/Comparison/SummaryMetricComparison.php @@ -84,6 +84,14 @@ public static function between(RequestSummary $baseline, RequestSummary $target) ]; } + /** + * Returns whether the metric changed between the baseline and the target. + */ + public function hasDifference(): bool + { + return $this->delta !== 'No change'; + } + private static function formatNumber(float|int $value, string $unit, int $precision): string { $formatted = number_format($value, $precision, '.', ','); diff --git a/src/Data/FilterPrefix.php b/src/Data/FilterPrefix.php index 8e10080..9d66393 100644 --- a/src/Data/FilterPrefix.php +++ b/src/Data/FilterPrefix.php @@ -9,25 +9,58 @@ */ final class FilterPrefix { + /** + * Query-parameter prefix for the Asset Bundles panel filters. + */ public const string ASSET = 'Asset'; + /** + * Query-parameter prefix for the Database panel filters. + */ public const string DB = 'Db'; + /** + * Query-parameter prefix for the request history panel filters. + */ public const string DEBUG = 'Debug'; + /** + * Query-parameter prefix for the Events panel filters. + */ public const string EVENT = 'Event'; + /** + * Query-parameter prefix for the Logs panel filters. + */ public const string LOG = 'Log'; + /** + * Query-parameter prefix for the Mail panel filters. + */ public const string MAIL = 'Mail'; + /** + * Query-parameter prefix for the Profiling panel filters. + */ public const string PROFILE = 'Profile'; + /** + * Query-parameter prefix for the Queue panel filters. + */ public const string QUEUE = 'Queue'; + /** + * Query-parameter prefix for the Router panel filters. + */ public const string ROUTER = 'Router'; + /** + * Query-parameter prefix for the Timeline panel filters. + */ public const string TIMELINE = 'Timeline'; + /** + * Query-parameter prefix for the User panel filters. + */ public const string USER = 'User'; } diff --git a/src/Data/PageSize.php b/src/Data/PageSize.php index 5a25827..1cd2caf 100644 --- a/src/Data/PageSize.php +++ b/src/Data/PageSize.php @@ -20,12 +20,10 @@ final class PageSize * Default page size applied when no `per-page` parameter is supplied or the value is invalid. */ public const int DEFAULT = 50; - /** * Hard cap on the number of rows per page. */ public const int MAX = 1000; - /** * Selector options in display order; the literal `all` disables pagination. */ @@ -90,7 +88,7 @@ public static function selectorFor(array $queryParams): string * * @param string $current Currently selected raw value (one of {@see OPTIONS} for a highlighted option). */ - public static function selectorHtml(string $current): string + private static function selectorHtml(string $current): string { $select = Select::tag() ->addDataAttribute('yii-debug-pagesize', true) diff --git a/src/Data/QueryInput.php b/src/Data/QueryInput.php index 44eb007..91d62bd 100644 --- a/src/Data/QueryInput.php +++ b/src/Data/QueryInput.php @@ -5,8 +5,10 @@ namespace PHPForge\Debug\Data; use function is_array; +use function is_finite; use function is_float; use function is_int; +use function is_numeric; use function is_string; /** @@ -49,6 +51,25 @@ public static function group(array $query, string $prefix): array return $filters; } + /** + * Returns a submitted lower bound as a finite, non-negative number, or `null` when the value is unusable. + * + * Empty, non-numeric, negative, and overflowing values are rejected, so callers can clear the stored filter + * whenever `null` comes back. + * + * @param string $value Raw submitted bound. + */ + public static function minimumBound(string $value): float|null + { + if (!is_numeric($value)) { + return null; + } + + $bound = (float) $value; + + return is_finite($bound) && $bound >= 0.0 ? $bound : null; + } + /** * Returns a top-level query parameter as a string, or `null` when absent or non-scalar. * diff --git a/src/Helper/Avatar.php b/src/Helper/Avatar.php index 5f19ec6..3141f09 100644 --- a/src/Helper/Avatar.php +++ b/src/Helper/Avatar.php @@ -6,10 +6,12 @@ use function abs; use function crc32; +use function mb_strtoupper; +use function mb_substr; use function strtolower; /** - * Derives stable, deterministic avatar colours from arbitrary identifying strings. + * Derives stable, deterministic avatar colours and monogram initials from arbitrary identifying strings. */ final class Avatar { @@ -33,4 +35,22 @@ public static function hueFor(string $seed): int return abs(crc32(strtolower($seed))) % 360; } + + /** + * Returns the uppercased first character of the given seed, or `'?'` when the seed is empty. + * + * Multibyte-safe: the initial is taken as one character, not one byte. + * + * @param string $seed Identifying value the monogram is derived from. + * + * @return string Single uppercased character, or `'?'`. + */ + public static function initial(string $seed): string + { + if ($seed === '') { + return '?'; + } + + return mb_strtoupper(mb_substr($seed, 0, 1)); + } } diff --git a/src/Helper/Badge.php b/src/Helper/Badge.php new file mode 100644 index 0000000..8f2455b --- /dev/null +++ b/src/Helper/Badge.php @@ -0,0 +1,31 @@ +class($modifier === '' ? $class : "{$class} {$modifier}") + ->content($label); + } +} diff --git a/src/Helper/ExtensionPill.php b/src/Helper/ExtensionPill.php new file mode 100644 index 0000000..3bce000 --- /dev/null +++ b/src/Helper/ExtensionPill.php @@ -0,0 +1,48 @@ +addAriaAttribute('hidden', 'true') + ->class('yii-debug-ext-pill-dot'), + Span::tag() + ->class('yii-debug-ext-pill-label') + ->content($label), + Span::tag() + ->class('yii-debug-ext-pill-state') + ->content($state), + ]; + + if ($summary !== '') { + $children[] = Span::tag() + ->class('yii-debug-sr-only') + ->content($summary); + } + + return Span::tag() + ->class('yii-debug-ext-pill ' . ($enabled ? 'is-on' : 'is-off')) + ->html(...$children); + } +} diff --git a/src/Helper/Format.php b/src/Helper/Format.php index 7ba09b0..6a64178 100644 --- a/src/Helper/Format.php +++ b/src/Helper/Format.php @@ -4,23 +4,50 @@ namespace PHPForge\Debug\Helper; +use function abs; use function count; +use function date; use function gettype; +use function intdiv; use function is_array; use function is_bool; use function is_float; use function is_int; use function is_string; +use function number_format; use function rtrim; use function sprintf; use function strlen; /** - * Formats values and type labels for display in debug-panel views and toolbar chips. + * Formats values, timestamps, and type labels for display in debug-panel views and toolbar chips. */ final class Format { + /** + * Bytes in one mebibyte, the unit of the `bytesToMb()` readout. + */ private const int BYTES_PER_MB = 1024 * 1024; + /** + * Milliseconds in one second, used to scale second-based durations. + */ + private const int MILLISECONDS_PER_SECOND = 1000; + /** + * Relative-time threshold: ages of one day or more render as `X d ago`. + */ + private const int SECONDS_PER_DAY = 86400; + /** + * Relative-time threshold: ages of one hour or more render as `X h ago`. + */ + private const int SECONDS_PER_HOUR = 3600; + /** + * Relative-time threshold: ages of one minute or more render as `X min ago`. + */ + private const int SECONDS_PER_MINUTE = 60; + /** + * Relative-time upper bound: ages of 30 days or more fall back to the absolute label. + */ + private const int SECONDS_PER_MONTH = 2592000; /** * Returns a `N.NN MB` string for the given byte count, rounded to the requested precision. @@ -51,6 +78,60 @@ public static function cssPercent(float $value): string return "{$rendered}%"; } + /** + * Returns a `N ms` readout for the given duration in seconds, grouped in thousands. + * + * @param float $seconds Duration in seconds. + * @param int $decimals Number of decimal places. + * + * @return string Millisecond readout. + */ + public static function milliseconds(float $seconds, int $decimals = 0): string + { + return number_format($seconds * self::MILLISECONDS_PER_SECOND, $decimals) . ' ms'; + } + + /** + * Returns a coarse age label for the given elapsed seconds: `just now` under a minute, then `X min ago`, `X h ago`, + * and `X d ago`. Ages of 30 days or more fall back to `$fallback`. + * + * The caller supplies the elapsed seconds so the clock source stays at the call site. + * + * @param int $elapsedSeconds Age in seconds, typically `time() - $capturedAt`. + * @param string $fallback Absolute label used past the 30-day threshold. + * + * @return string Age label, or `$fallback` when the age reaches 30 days. + */ + public static function relativeTime(int $elapsedSeconds, string $fallback): string + { + return match (true) { + $elapsedSeconds < self::SECONDS_PER_MINUTE => 'just now', + $elapsedSeconds < self::SECONDS_PER_HOUR => intdiv($elapsedSeconds, self::SECONDS_PER_MINUTE) . ' min ago', + $elapsedSeconds < self::SECONDS_PER_DAY => intdiv($elapsedSeconds, self::SECONDS_PER_HOUR) . ' h ago', + $elapsedSeconds < self::SECONDS_PER_MONTH => intdiv($elapsedSeconds, self::SECONDS_PER_DAY) . ' d ago', + default => $fallback, + }; + } + + /** + * Returns the wall-clock readout of the given epoch milliseconds, suffixed with the millisecond fraction + * (`H:i:s.mmm`). + * + * The fraction is rendered as an absolute value, so the `.mmm` segment always spans three digits. + * + * @param int $epochMilliseconds Unix timestamp in milliseconds. + * @param string $format `date()` format for the second-precision part, without the fraction separator. + * + * @return string Formatted timestamp followed by `.mmm`. + */ + public static function timeOfDay(int $epochMilliseconds, string $format = 'H:i:s'): string + { + $seconds = intdiv($epochMilliseconds, self::MILLISECONDS_PER_SECOND); + $fraction = abs($epochMilliseconds % self::MILLISECONDS_PER_SECOND); + + return date("{$format}.", $seconds) . sprintf('%03d', $fraction); + } + /** * Returns the display label of a value's type, with the element count for arrays and the byte length for strings. * diff --git a/src/Helper/SensitiveDataRedactor.php b/src/Helper/SensitiveDataRedactor.php index b9d7e6b..9dd2669 100644 --- a/src/Helper/SensitiveDataRedactor.php +++ b/src/Helper/SensitiveDataRedactor.php @@ -75,10 +75,21 @@ final class SensitiveDataRedactor public const array DEFAULT_PATTERNS = [ '~(?:^|[_\-.])(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)(?:$|[_\-.])~i', ]; + /** + * Replacement written in place of a sensitive value. + */ public const string PLACEHOLDER = '[redacted]'; + /** + * Replacement written where the depth or node budget stops traversal. + */ public const string TRUNCATED = '[truncated]'; - + /** + * Nesting depth beyond which nested arrays are replaced by {@see TRUNCATED}. + */ private const int MAX_DEPTH = 10; + /** + * Maximum entries visited per `redact()` call before traversal stops with {@see TRUNCATED}. + */ private const int MAX_NODES = 10000; /** @@ -100,6 +111,22 @@ public static function isSensitiveKey( return self::matches($key, $keyMap, $prefixes, $patterns); } + /** + * Resolves the pattern list a redaction rule set applies, preserving exact-list override behavior. + * + * An explicit list wins. Otherwise {@see DEFAULT_PATTERNS} applies only alongside the unmodified + * {@see DEFAULT_KEYS} list, so a custom exact-key list never inherits the segment-aware defaults. + * + * @param list $keys Configured exact key names. + * @param list|null $patterns Configured PCRE patterns, or `null` to resolve the defaults. + * + * @return list Patterns applied to complete original keys. + */ + public static function patterns(array $keys, array|null $patterns): array + { + return $patterns ?? ($keys === self::DEFAULT_KEYS ? self::DEFAULT_PATTERNS : []); + } + /** * Redacts configured keys case-insensitively throughout a bounded nested array. * @@ -180,19 +207,6 @@ private static function matches( return false; } - /** - * Preserves exact-list override behavior while enabling safer defaults for the default policy. - * - * @param list $keys - * @param list|null $patterns - * - * @return list - */ - private static function patterns(array $keys, array|null $patterns): array - { - return $patterns ?? ($keys === self::DEFAULT_KEYS ? self::DEFAULT_PATTERNS : []); - } - /** * Normalizes literal key prefixes and rejects an empty match-all prefix. * diff --git a/src/Helper/Table.php b/src/Helper/Table.php new file mode 100644 index 0000000..5e88a14 --- /dev/null +++ b/src/Helper/Table.php @@ -0,0 +1,72 @@ +` element, prepending a `` row of `` cells when labels are supplied. + * + * Exposed on its own so sections that decorate the shell (extra attributes, a fixed layout) still share one + * header and body contract. + * + * @param list $headers Column labels, or `[]` for a table without a header row. + * @param list $rows Body rows in display order. + * @param string $class Table CSS classes. + * + * @return HtmlTable Table element. + */ + public static function build(array $headers, array $rows, string $class = 'yii-debug-table'): HtmlTable + { + $table = HtmlTable::tag()->class($class); + + if ($headers === []) { + return $table + ->html(Tbody::tag() + ->html(...$rows)); + } + + $cells = []; + + foreach ($headers as $header) { + $cells[] = Th::tag() + ->scope('col') + ->content($header); + } + + return $table->html( + Thead::tag()->html(Tr::tag()->html(...$cells)), + Tbody::tag()->html(...$rows), + ); + } + + /** + * Renders the table shell inside its scroll wrapper. + * + * @param list $headers Column labels, or `[]` for a table without a header row. + * @param list $rows Body rows in display order. + * @param string $tableClass Table CSS classes. + * @param string $wrapClass Scroll-wrapper CSS classes. + * + * @return string Wrapped table markup. + */ + public static function render( + array $headers, + array $rows, + string $tableClass = 'yii-debug-table', + string $wrapClass = 'yii-debug-table-wrap', + ): string { + return Div::tag() + ->class($wrapClass) + ->html(self::build($headers, $rows, $tableClass)) + ->render(); + } +} diff --git a/src/Panel/Asset/AssetCardRenderer.php b/src/Panel/Asset/AssetCardRenderer.php index 3be23ab..137418d 100644 --- a/src/Panel/Asset/AssetCardRenderer.php +++ b/src/Panel/Asset/AssetCardRenderer.php @@ -4,7 +4,7 @@ namespace PHPForge\Debug\Panel\Asset; -use PHPForge\Debug\Helper\{Icon, Text}; +use PHPForge\Debug\Helper\{Fqcn, Icon, Text}; use UIAwesome\Html\Flow\Div; use UIAwesome\Html\Heading\{H2, H3}; use UIAwesome\Html\List\{Dd, Dl, Dt}; @@ -14,8 +14,6 @@ use UIAwesome\Html\Sectioning\{Article, Section}; use function array_map; -use function strrpos; -use function substr; /** * Renders the per-bundle markup for the Asset Bundles detail view. @@ -26,12 +24,11 @@ final class AssetCardRenderer * Renders one bundle as an `
` ready to drop into the detail view. * * @param AssetBundleView $bundle Per-bundle view-model. - * @param AssetSummary $summary Full summary, used to resolve `#anchor` targets for cross-bundle dependency links. */ - public static function renderCard(AssetBundleView $bundle, AssetSummary $summary): Article + public static function renderCard(AssetBundleView $bundle): Article { $head = self::renderHead($bundle); - $bodyChildren = self::renderBody($bundle, $summary); + $bodyChildren = self::renderBody($bundle); $articleChildren = [$head]; @@ -48,19 +45,6 @@ public static function renderCard(AssetBundleView $bundle, AssetSummary $summary ->html(...$articleChildren); } - /** - * Resolves the anchor id for a dependency name. - * - * Uses the same canonical {@see \\PHPForge\\Debug\\Helper\\Text::camel2id()} conversion as bundle registration. - * - * @param string $depName Fully qualified class name of the dependency. - * @param AssetSummary $summary Already-normalized summary, retained for backward compatibility. - */ - public static function resolveAnchor(string $depName, AssetSummary $summary): string - { - return Text::camel2id($depName); - } - /** * Builds the optional card body (Files and Wiring sections), collapsing to an empty list when neither applies. * @@ -68,7 +52,7 @@ public static function resolveAnchor(string $depName, AssetSummary $summary): st * * @return list
Body sections in render order. */ - private static function renderBody(AssetBundleView $bundle, AssetSummary $summary): array + private static function renderBody(AssetBundleView $bundle): array { $body = []; @@ -77,7 +61,7 @@ private static function renderBody(AssetBundleView $bundle, AssetSummary $summar } if ($bundle->hasWiring || $bundle->hasDepends) { - $body[] = self::renderWiringSection($bundle, $summary); + $body[] = self::renderWiringSection($bundle); } return $body; @@ -103,13 +87,12 @@ private static function renderChip(string $modifier, int $count, string $singula } /** - * Renders one dependency link with the short name visible, the full FQCN in `title`, and the anchor resolved via - * {@see self::resolveAnchor()}. + * Renders one dependency link with the short name visible, the full FQCN in `title`, and the `#anchor` derived + * from the dependency name through the same {@see Text::camel2id()} rule used for bundle card ids. */ - private static function renderDepend(string $depName, AssetSummary $summary): A + private static function renderDepend(string $depName): A { - $pos = strrpos($depName, '\\'); - $shortName = $pos === false ? $depName : substr($depName, $pos + 1); + $shortName = Fqcn::shortName($depName); return A::tag() ->class('yii-debug-asset-depend') @@ -122,7 +105,7 @@ private static function renderDepend(string $depName, AssetSummary $summary): A ->class('yii-debug-asset-depend-name') ->content($shortName), ) - ->href('#' . self::resolveAnchor($depName, $summary)) + ->href('#' . Text::camel2id($depName)) ->title($depName); } @@ -252,7 +235,7 @@ private static function renderWiringRow(string $label, string $value): Div * * The caller guarantees that at least one of `hasWiring` / `hasDepends` is `true`. */ - private static function renderWiringSection(AssetBundleView $bundle, AssetSummary $summary): Section + private static function renderWiringSection(AssetBundleView $bundle): Section { $sectionChildren = [ H3::tag() @@ -291,7 +274,7 @@ private static function renderWiringSection(AssetBundleView $bundle, AssetSummar ->class('yii-debug-asset-depends-list') ->html( ...array_map( - static fn(string $dep): A => self::renderDepend($dep, $summary), + static fn(string $dep): A => self::renderDepend($dep), $bundle->depends, ), ), diff --git a/src/Panel/Asset/AssetSectionRenderer.php b/src/Panel/Asset/AssetSectionRenderer.php index 140fd6e..ed5d7a5 100644 --- a/src/Panel/Asset/AssetSectionRenderer.php +++ b/src/Panel/Asset/AssetSectionRenderer.php @@ -72,7 +72,7 @@ public static function renderInventory(AssetSummary $summary): string foreach ($summary->bundles as $bundle) { $items[] = Li::tag() ->class('yii-debug-asset-list-item') - ->html(AssetCardRenderer::renderCard($bundle, $summary)); + ->html(AssetCardRenderer::renderCard($bundle)); } return Ol::tag() diff --git a/src/Panel/Config/ConfigCardRenderer.php b/src/Panel/Config/ConfigCardRenderer.php index 796bd36..6d3ded0 100644 --- a/src/Panel/Config/ConfigCardRenderer.php +++ b/src/Panel/Config/ConfigCardRenderer.php @@ -5,6 +5,7 @@ namespace PHPForge\Debug\Panel\Config; use Locale; +use PHPForge\Debug\Helper\ExtensionPill; use Stringable; use UIAwesome\Html\Flow\Div; use UIAwesome\Html\Heading\{H2, H3}; @@ -249,21 +250,7 @@ private static function renderDlRow(string $term, string $value): Div */ private static function renderExtensionPill(string $name, bool $enabled): Span { - $variant = $enabled ? 'is-on' : 'is-off'; - - return Span::tag() - ->class("yii-debug-ext-pill {$variant}") - ->html( - Span::tag() - ->addAriaAttribute('hidden', 'true') - ->class('yii-debug-ext-pill-dot'), - Span::tag() - ->class('yii-debug-ext-pill-label') - ->content($name), - Span::tag() - ->class('yii-debug-ext-pill-state') - ->content($enabled ? 'on' : 'off'), - ); + return ExtensionPill::render($name, $enabled ? 'on' : 'off', $enabled); } /** diff --git a/src/Panel/Db/DbExplainRenderer.php b/src/Panel/Db/DbExplainRenderer.php index 580b411..fb132ee 100644 --- a/src/Panel/Db/DbExplainRenderer.php +++ b/src/Panel/Db/DbExplainRenderer.php @@ -4,12 +4,12 @@ namespace PHPForge\Debug\Panel\Db; -use PHPForge\Debug\Helper\Dump; +use PHPForge\Debug\Helper\{Dump, Table}; use PHPForge\Debug\Panel\PanelTitle; use UIAwesome\Html\Flow\{Div, P, Pre}; use UIAwesome\Html\Heading\H1; use UIAwesome\Html\Phrasing\Em; -use UIAwesome\Html\Table\{Table, Tbody, Td, Th, Thead, Tr}; +use UIAwesome\Html\Table\{Td, Tr}; use function array_keys; use function array_values; @@ -69,12 +69,10 @@ private static function renderPlan(string $query, array $results, string|null $e ->class('yii-debug-explain-empty') ->content('EXPLAIN returned no rows.'); } else { - $headerCells = []; + $headers = []; foreach ($columns as $column) { - $headerCells[] = Th::tag() - ->scope('col') - ->content((string) $column); + $headers[] = (string) $column; } $bodyRows = []; @@ -94,16 +92,12 @@ private static function renderPlan(string $query, array $results, string|null $e $bodyRows[] = Tr::tag()->html(...$cells); } - $children[] = Div::tag() - ->class('yii-debug-explain-scroll') - ->html( - Table::tag() - ->class('yii-debug-table yii-debug-explain-table') - ->html( - Thead::tag()->html(Tr::tag()->html(...$headerCells)), - Tbody::tag()->html(...$bodyRows), - ), - ); + $children[] = Table::render( + $headers, + $bodyRows, + 'yii-debug-table yii-debug-explain-table', + 'yii-debug-explain-scroll', + ); } return Div::tag() diff --git a/src/Panel/Db/DbQueryRenderer.php b/src/Panel/Db/DbQueryRenderer.php index 817fdbb..8a5cb02 100644 --- a/src/Panel/Db/DbQueryRenderer.php +++ b/src/Panel/Db/DbQueryRenderer.php @@ -5,17 +5,15 @@ namespace PHPForge\Debug\Panel\Db; use Closure; -use PHPForge\Debug\Helper\Vocabulary; +use PHPForge\Debug\Helper\{Format, Vocabulary}; use UIAwesome\Html\Flow\Div; use UIAwesome\Html\List\{Li, Ul}; use UIAwesome\Html\Palpable\A; use UIAwesome\Html\Phrasing\{Span, Strong}; use function array_map; -use function date; use function implode; use function in_array; -use function intdiv; use function number_format; use function sprintf; use function strtoupper; @@ -26,24 +24,6 @@ */ final class DbQueryRenderer { - /** - * Returns whether the given query type produces a useful EXPLAIN plan. - * - * Only DML statements that touch tables (`SELECT`, `INSERT`, `UPDATE`, `DELETE`, `REPLACE`, `WITH`) are - * accepted; metadata, session-control, and transaction-control statements either error or return noise, so they - * are filtered out. - * - * @param string $type SQL command verb (case-insensitive). - */ - public static function canBeExplained(string $type): bool - { - return in_array( - strtoupper($type), - ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'WITH'], - true, - ); - } - /** * Renders the statement duration formatted as `N.N ms`. */ @@ -65,26 +45,27 @@ public static function renderNPlusOneSummary(array $findings, string $context = } $context = trim($context); - $context = $context === '' ? '' : " {$context}"; + $context = $context === '' ? '' : " {$context}"; $items = []; foreach ($findings as $finding) { $groupId = $finding->id(); - $items[] = Li::tag()->html( - A::tag() - ->addDataAttribute('yii-debug-n1-filter', $groupId) - ->class('yii-debug-db-n1-link') - ->href("#{$groupId}") - ->title($finding->representativeQuery) - ->html( - Strong::tag()->content("{$finding->count}×"), - Span::tag() - ->class('yii-debug-db-n1-fingerprint') - ->content($finding->representativeQuery), - Span::tag()->content(number_format($finding->totalDuration, 1) . ' ms'), - ), - ); + $items[] = Li::tag() + ->html( + A::tag() + ->addDataAttribute('yii-debug-n1-filter', $groupId) + ->class('yii-debug-db-n1-link') + ->href("#{$groupId}") + ->title($finding->representativeQuery) + ->html( + Strong::tag()->content("{$finding->count}×"), + Span::tag() + ->class('yii-debug-db-n1-fingerprint') + ->content($finding->representativeQuery), + Span::tag()->content(number_format($finding->totalDuration, 1) . ' ms'), + ), + ); } return Div::tag() @@ -102,7 +83,9 @@ public static function renderNPlusOneSummary(array $findings, string $context = ->content('Show all queries') ->href('#'), ), - Ul::tag()->class('yii-debug-db-n1-list')->html(...$items), + Ul::tag() + ->class('yii-debug-db-n1-list') + ->html(...$items), Span::tag() ->addAriaAttribute('atomic', 'true') ->addAriaAttribute('live', 'polite') @@ -215,9 +198,7 @@ public static function renderRowsCell(QueryRow $row): string */ public static function renderTimeCell(QueryRow $row): string { - $milliseconds = (int) $row->timestamp; - - return date('H:i:s.', intdiv($milliseconds, 1000)) . sprintf('%03d', $milliseconds % 1000); + return Format::timeOfDay((int) $row->timestamp); } /** @@ -232,4 +213,22 @@ public static function renderTypeCell(QueryRow $row): string ->content($row->type) ->render(); } + + /** + * Returns whether the given query type produces a useful EXPLAIN plan. + * + * Only DML statements that touch tables (`SELECT`, `INSERT`, `UPDATE`, `DELETE`, `REPLACE`, `WITH`) are + * accepted; metadata, session-control, and transaction-control statements either error or return noise, so they + * are filtered out. + * + * @param string $type SQL command verb (case-insensitive). + */ + private static function canBeExplained(string $type): bool + { + return in_array( + strtoupper($type), + ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'WITH'], + true, + ); + } } diff --git a/src/Panel/Db/DbSnapshot.php b/src/Panel/Db/DbSnapshot.php index 37f69d7..0d98b64 100644 --- a/src/Panel/Db/DbSnapshot.php +++ b/src/Panel/Db/DbSnapshot.php @@ -28,16 +28,11 @@ public function entries(): array public static function fromArray(mixed $data, string $path): self { - $payload = Payload::object($data, $path) - ->shape(['entries']); - - $entries = []; - - foreach ($payload->list('entries') as $index => $entry) { - $entries[] = QueryRow::fromArray($entry, "{$path}.entries[{$index}]"); - } - - return new self($entries); + return new self( + Payload::object($data, $path) + ->shape(['entries']) + ->mapList('entries', QueryRow::fromArray(...)), + ); } /** diff --git a/src/Panel/Db/NPlusOneFinding.php b/src/Panel/Db/NPlusOneFinding.php index 01e379a..40147ed 100644 --- a/src/Panel/Db/NPlusOneFinding.php +++ b/src/Panel/Db/NPlusOneFinding.php @@ -25,11 +25,6 @@ public function __construct( public string $representativeQuery, ) {} - public function contains(QueryRow $row): bool - { - return $row->traceHash === $this->fingerprint; - } - public function id(): string { return "yii-debug-db-n1-{$this->firstSequence}"; diff --git a/src/Panel/Dump/DumpCardRenderer.php b/src/Panel/Dump/DumpCardRenderer.php index 3616388..6170c63 100644 --- a/src/Panel/Dump/DumpCardRenderer.php +++ b/src/Panel/Dump/DumpCardRenderer.php @@ -5,7 +5,7 @@ namespace PHPForge\Debug\Panel\Dump; use Closure; -use PHPForge\Debug\Helper\Coerce; +use PHPForge\Debug\Helper\{Coerce, Format}; use UIAwesome\Html\Flow\Div; use UIAwesome\Html\List\{Li, Ul}; use UIAwesome\Html\Phrasing\Span; @@ -15,7 +15,6 @@ use function array_pop; use function basename; use function count; -use function date; use function html_entity_decode; use function htmlspecialchars; use function implode; @@ -26,7 +25,6 @@ use function preg_match; use function preg_replace; use function preg_split; -use function sprintf; use function str_replace; use function strip_tags; use function strtolower; @@ -89,9 +87,7 @@ private static function formatTime(float $time): string return ''; } - $millis = intval($time * 1000) % 1000; - - return date('H:i:s', (int) $time) . '.' . sprintf('%03d', $millis); + return Format::timeOfDay(intval($time * 1000)); } /** @@ -101,8 +97,7 @@ private static function formatTime(float $time): string */ private static function renderBody(DumpRow $row, Closure $traceLine): Div { - $body = Div::tag() - ->class('yii-debug-dump-body'); + $body = Div::tag()->class('yii-debug-dump-body'); $message = self::sanitizeMessage($row->message); @@ -204,6 +199,7 @@ private static function sanitizeMessage(string $message): string -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY, ); + $parts = $parts === false ? [] : $parts; /** @var list $openTags */ @@ -214,13 +210,16 @@ private static function sanitizeMessage(string $message): string $part === '<pre>' => ['tag' => 'pre', 'html' => '
'],
                 preg_match('/^<(code|span) style="color: (#[0-9A-Fa-f]{6})">/', $part, $match) === 1 => [
                     'tag' => $match[1],
-                    'html' => '<' . $match[1] . ' style="color: ' . $match[2] . '">',
+                    'html' => "<{$match[1]} style=\"color: {$match[2]}\">",
                 ],
                 default => null,
             };
 
             if ($opening !== null) {
-                $openTags[] = [...$opening, 'index' => $index];
+                $openTags[] = [
+                    ...$opening,
+                    'index' => $index,
+                ];
 
                 continue;
             }
diff --git a/src/Panel/Dump/DumpSnapshot.php b/src/Panel/Dump/DumpSnapshot.php
index 6e71f20..543a757 100644
--- a/src/Panel/Dump/DumpSnapshot.php
+++ b/src/Panel/Dump/DumpSnapshot.php
@@ -46,16 +46,11 @@ public function entries(): array
 
     public static function fromArray(mixed $data, string $path): self
     {
-        $payload = Payload::object($data, $path)
-            ->shape(['entries']);
-
-        $entries = [];
-
-        foreach ($payload->list('entries') as $index => $entry) {
-            $entries[] = DumpRow::fromArray($entry, "{$path}.entries[{$index}]");
-        }
-
-        return new self($entries);
+        return new self(
+            Payload::object($data, $path)
+                ->shape(['entries'])
+                ->mapList('entries', DumpRow::fromArray(...)),
+        );
     }
 
     /**
diff --git a/src/Panel/Event/EventCellRenderer.php b/src/Panel/Event/EventCellRenderer.php
index c21c788..9e2ac92 100644
--- a/src/Panel/Event/EventCellRenderer.php
+++ b/src/Panel/Event/EventCellRenderer.php
@@ -4,12 +4,7 @@
 
 namespace PHPForge\Debug\Panel\Event;
 
-use PHPForge\Debug\Helper\Fqcn;
-use UIAwesome\Html\Phrasing\Span;
-
-use function date;
-use function intdiv;
-use function sprintf;
+use PHPForge\Debug\Helper\{Badge, Format, Fqcn};
 
 /**
  * Renders the typed cells of the events grid for the Event debug panel.
@@ -41,10 +36,7 @@ public static function renderStaticCell(EventRow $row): string
             return '—';
         }
 
-        return Span::tag()
-            ->class('yii-debug-badge yii-debug-badge-muted')
-            ->content('static')
-            ->render();
+        return Badge::render('static', 'muted')->render();
     }
 
     /**
@@ -52,8 +44,6 @@ public static function renderStaticCell(EventRow $row): string
      */
     public static function renderTimeCell(EventRow $row): string
     {
-        $milliseconds = (int) ($row->time * 1000);
-
-        return date('H:i:s.', intdiv($milliseconds, 1000)) . sprintf('%03d', $milliseconds % 1000);
+        return Format::timeOfDay((int) ($row->time * 1000));
     }
 }
diff --git a/src/Panel/Event/EventSnapshot.php b/src/Panel/Event/EventSnapshot.php
index eb58516..30f851c 100644
--- a/src/Panel/Event/EventSnapshot.php
+++ b/src/Panel/Event/EventSnapshot.php
@@ -28,16 +28,11 @@ public function entries(): array
 
     public static function fromArray(mixed $data, string $path): self
     {
-        $payload = Payload::object($data, $path)
-            ->shape(['entries']);
-
-        $entries = [];
-
-        foreach ($payload->list('entries') as $index => $entry) {
-            $entries[] = EventRow::fromArray($entry, "{$path}.entries[{$index}]");
-        }
-
-        return new self($entries);
+        return new self(
+            Payload::object($data, $path)
+                ->shape(['entries'])
+                ->mapList('entries', EventRow::fromArray(...)),
+        );
     }
 
     /**
diff --git a/src/Panel/Log/LogCellRenderer.php b/src/Panel/Log/LogCellRenderer.php
index 547d5f4..3116901 100644
--- a/src/Panel/Log/LogCellRenderer.php
+++ b/src/Panel/Log/LogCellRenderer.php
@@ -5,7 +5,7 @@
 namespace PHPForge\Debug\Panel\Log;
 
 use Closure;
-use PHPForge\Debug\Helper\{CellMore, Fqcn, LogLevel, Vocabulary};
+use PHPForge\Debug\Helper\{CellMore, Format, Fqcn, LogLevel, Vocabulary};
 use PHPForge\Debug\Panel\Db\SqlHighlighter;
 use UIAwesome\Html\Flow\Div;
 use UIAwesome\Html\Helper\Encode;
@@ -14,10 +14,8 @@
 use UIAwesome\Html\Phrasing\Span;
 
 use function array_map;
-use function date;
 use function implode;
 use function intdiv;
-use function sprintf;
 use function str_starts_with;
 
 /**
@@ -120,11 +118,7 @@ public static function renderMessageCell(LogRow $row, Closure $traceLine): strin
      */
     public static function renderTimeCell(LogRow $row): string
     {
-        $timestamp = (int) $row->time;
-        $seconds = intdiv($timestamp, 1000);
-        $millis = $timestamp % 1000;
-
-        return date('H:i:s.', $seconds) . sprintf('%03d', $millis);
+        return Format::timeOfDay((int) $row->time);
     }
 
     /**
diff --git a/src/Panel/Log/LogSnapshot.php b/src/Panel/Log/LogSnapshot.php
index c20bcf3..5876bb9 100644
--- a/src/Panel/Log/LogSnapshot.php
+++ b/src/Panel/Log/LogSnapshot.php
@@ -45,9 +45,7 @@ public static function capture(array $messages): self
 
         foreach ($messages as $index => $message) {
             $id = $index + 1;
-
             $timestamp = $message[3];
-
             $previousTime ??= $timestamp;
 
             $entries[] = LogRow::fromLoggerTuple(
@@ -75,16 +73,11 @@ public function entries(): array
 
     public static function fromArray(mixed $data, string $path): self
     {
-        $payload = Payload::object($data, $path)
-            ->shape(['entries']);
-
-        $entries = [];
-
-        foreach ($payload->list('entries') as $index => $entry) {
-            $entries[] = LogRow::fromArray($entry, "{$path}.entries[{$index}]");
-        }
-
-        return new self($entries);
+        return new self(
+            Payload::object($data, $path)
+                ->shape(['entries'])
+                ->mapList('entries', LogRow::fromArray(...)),
+        );
     }
 
     /**
diff --git a/src/Panel/Mail/MailCardRenderer.php b/src/Panel/Mail/MailCardRenderer.php
index e75ccd2..d3f00b1 100644
--- a/src/Panel/Mail/MailCardRenderer.php
+++ b/src/Panel/Mail/MailCardRenderer.php
@@ -4,7 +4,7 @@
 
 namespace PHPForge\Debug\Panel\Mail;
 
-use PHPForge\Debug\Helper\{Avatar, Icon};
+use PHPForge\Debug\Helper\{Avatar, Format, Icon};
 use UIAwesome\Html\Flow\{Div, Pre};
 use UIAwesome\Html\Heading\H2;
 use UIAwesome\Html\Helper\Encode;
@@ -17,9 +17,7 @@
 use function array_map;
 use function date;
 use function explode;
-use function intdiv;
 use function mb_strlen;
-use function mb_strtoupper;
 use function mb_substr;
 
 /**
@@ -27,6 +25,9 @@
  */
 final class MailCardRenderer
 {
+    /**
+     * Maximum number of characters shown in the body preview.
+     */
     private const int BODY_PREVIEW_LIMIT = 140;
 
     /**
@@ -75,39 +76,16 @@ private static function bodyPreview(string $body): string
     /**
      * Formats a Unix timestamp into `[relative, absolute]` strings for the meta time line.
      *
-     * The relative form returns `'just now'` for under a minute, `'X min ago'` / `'X h ago'` / `'X d ago'` for the
-     * matching thresholds, and falls back to the absolute form past 30 days.
+     * The relative form follows the shared age scale ({@see Format::relativeTime()}) and falls back to the absolute
+     * form past 30 days.
      *
      * @return array{0: string, 1: string} Relative label and absolute label, in that order.
      */
     private static function formatTime(int $unix): array
     {
         $absolute = date('M j, Y · H:i:s', $unix);
-        $diff = time() - $unix;
-
-        if ($diff < 60) {
-            return ['just now', $absolute];
-        }
-
-        if ($diff < 3600) {
-            $minutes = intdiv($diff, 60);
-
-            return ["{$minutes} min ago", $absolute];
-        }
-
-        if ($diff < 86400) {
-            $hours = intdiv($diff, 3600);
-
-            return ["{$hours} h ago", $absolute];
-        }
-
-        if ($diff < 2592000) {
-            $days = intdiv($diff, 86400);
 
-            return ["{$days} d ago", $absolute];
-        }
-
-        return [$absolute, $absolute];
+        return [Format::relativeTime(time() - $unix, $absolute), $absolute];
     }
 
     /**
@@ -125,15 +103,9 @@ private static function hasRecipients(MailMessage $message): bool
      */
     private static function initialsFor(string $email): string
     {
-        if ($email === '') {
-            return '?';
-        }
-
         $local = explode('@', $email)[0];
 
-        $seed = $local !== '' ? $local : $email;
-
-        return mb_strtoupper(mb_substr($seed, 0, 1));
+        return Avatar::initial($local !== '' ? $local : $email);
     }
 
     /**
@@ -277,7 +249,9 @@ private static function renderRecipients(MailMessage $message): Div
                         ->addDataAttribute('role', $key)
                         ->class('yii-debug-mail-recipient-label')
                         ->content($group['label']),
-                    Span::tag()->class('yii-debug-mail-recipient-pills')->html(...$pills),
+                    Span::tag()
+                        ->class('yii-debug-mail-recipient-pills')
+                        ->html(...$pills),
                 );
         }
 
@@ -301,7 +275,9 @@ private static function renderStatus(MailMessage $message): Span
             ->class("yii-debug-mail-status yii-debug-mail-status-{$variant}")
             ->title($tooltip)
             ->html(
-                Span::tag()->addAriaAttribute('hidden', 'true')->class('yii-debug-mail-status-dot'),
+                Span::tag()
+                    ->addAriaAttribute('hidden', 'true')
+                    ->class('yii-debug-mail-status-dot'),
                 " {$encodedLabel}",
             );
     }
@@ -318,7 +294,9 @@ private static function renderTechDetails(MailMessage $message): Details
                 ->class('yii-debug-mail-tech-icon')
                 ->addAriaAttribute('hidden', 'true')
                 ->html(Icon::render('code')),
-            Span::tag()->class('yii-debug-mail-tech-label')->content('Raw headers'),
+            Span::tag()
+                ->class('yii-debug-mail-tech-label')
+                ->content('Raw headers'),
         ];
 
         if ($message->charset !== '') {
@@ -337,7 +315,9 @@ private static function renderTechDetails(MailMessage $message): Details
             ->class('yii-debug-mail-tech')
             ->html(
                 Summary::tag()->html(...$summaryChildren),
-                Pre::tag()->class('yii-debug-mail-headers')->content($message->headers),
+                Pre::tag()
+                    ->class('yii-debug-mail-headers')
+                    ->content($message->headers),
             );
     }
 }
diff --git a/src/Panel/Mail/MailSnapshot.php b/src/Panel/Mail/MailSnapshot.php
index f09dc64..283709c 100644
--- a/src/Panel/Mail/MailSnapshot.php
+++ b/src/Panel/Mail/MailSnapshot.php
@@ -47,17 +47,10 @@ public function entries(): array
 
     public static function fromArray(mixed $data, string $path): self
     {
-        $payload = Payload::object($data, $path)
-            ->shape(['entries']);
-
-        $entries = [];
-
-        foreach ($payload->list('entries') as $index => $entry) {
-            $entries[] = MailMessage::fromArray($entry, "{$path}.entries[{$index}]");
-        }
-
         return new self(
-            $entries,
+            Payload::object($data, $path)
+                ->shape(['entries'])
+                ->mapList('entries', MailMessage::fromArray(...)),
         );
     }
 
diff --git a/src/Panel/PanelRenderContext.php b/src/Panel/PanelRenderContext.php
index 37168ab..d78a1ea 100644
--- a/src/Panel/PanelRenderContext.php
+++ b/src/Panel/PanelRenderContext.php
@@ -28,27 +28,6 @@ public function __construct(
         private array $panels = [],
     ) {}
 
-    /**
-     * Builds an adapter action URL for this captured request.
-     *
-     * @param string $action Adapter-defined action identifier.
-     * @param array|null $queryParams Additional parameters, or `null` to reuse the current query.
-     */
-    public function actionUrl(string $action, array|null $queryParams = null): string
-    {
-        return $this->urls->action($action, $this->tag, $queryParams ?? $this->queryParams);
-    }
-
-    /**
-     * Builds the request-history URL.
-     *
-     * @param array|null $queryParams History parameters, or `null` to reuse the current query.
-     */
-    public function historyUrl(array|null $queryParams = null): string
-    {
-        return $this->urls->history($queryParams ?? $this->queryParams);
-    }
-
     /**
      * Returns another panel payload from the current snapshot when cross-panel composition is required.
      *
diff --git a/src/Panel/Profile/ProfileCellRenderer.php b/src/Panel/Profile/ProfileCellRenderer.php
index 9daba19..abc06e0 100644
--- a/src/Panel/Profile/ProfileCellRenderer.php
+++ b/src/Panel/Profile/ProfileCellRenderer.php
@@ -4,14 +4,12 @@
 
 namespace PHPForge\Debug\Panel\Profile;
 
-use PHPForge\Debug\Helper\{CellMore, Fqcn, Gauge};
+use PHPForge\Debug\Helper\{CellMore, Format, Fqcn, Gauge};
 use PHPForge\Debug\Panel\Db\SqlHighlighter;
 use UIAwesome\Html\Flow\Div;
 use UIAwesome\Html\Helper\Encode;
 use UIAwesome\Html\Phrasing\Span;
 
-use function date;
-use function intdiv;
 use function sprintf;
 use function str_repeat;
 use function str_starts_with;
@@ -39,8 +37,8 @@ public static function renderCategoryCell(ProfileRow $row): string
     }
 
     /**
-     * Renders the block duration formatted as `N.N ms`, with a micro-gauge rail scaled against the capture maximum
-     * when one exists.
+     * Renders the block duration formatted as `N.N ms`, with a micro-gauge rail scaled against the capture maximum when
+     * one exists.
      *
      * @param ProfileRow $row Typed profile row.
      * @param float $maxDuration Capture maximum in milliseconds ({@see ProfileRow::maxDuration()}).
@@ -87,12 +85,9 @@ public static function renderTimeCell(ProfileRow $row): string
     {
         $milliseconds = (int) $row->timestamp;
 
-        $seconds = intdiv($milliseconds, 1000);
-        $suffix = sprintf('%03d', $milliseconds % 1000);
-
         return Span::tag()
-            ->title(date('Y-m-d H:i:s.', $seconds) . $suffix)
-            ->content(date('H:i:s.', $seconds) . $suffix)
+            ->title(Format::timeOfDay($milliseconds, 'Y-m-d H:i:s'))
+            ->content(Format::timeOfDay($milliseconds))
             ->render();
     }
 
diff --git a/src/Panel/Queue/QueueCardRenderer.php b/src/Panel/Queue/QueueCardRenderer.php
index a9e3774..9869353 100644
--- a/src/Panel/Queue/QueueCardRenderer.php
+++ b/src/Panel/Queue/QueueCardRenderer.php
@@ -23,7 +23,6 @@
 use function is_int;
 use function is_string;
 use function mb_strlen;
-use function mb_strtoupper;
 use function mb_substr;
 use function sprintf;
 
@@ -105,20 +104,6 @@ public static function renderItem(JobRecord $record): Article
             ->html(...$children);
     }
 
-    /**
-     * Returns the uppercased first letter of the short class name, falling back to `'?'` when empty.
-     */
-    private static function initialFor(string $jobClass): string
-    {
-        $shortName = Fqcn::shortName($jobClass);
-
-        if ($shortName === '') {
-            return '?';
-        }
-
-        return mb_strtoupper(mb_substr($shortName, 0, 1));
-    }
-
     /**
      * Renders one meta-line item with its label and value.
      */
@@ -148,7 +133,6 @@ private static function metaItem(string $label, string $value): Span
     private static function renderArrayOrObjectRow(string $key, array $value): Details
     {
         $isObject = isset($value['__class']) && is_string($value['__class']);
-
         $isList = !$isObject && array_is_list($value);
 
         $children = [];
@@ -191,6 +175,7 @@ private static function renderArrayOrObjectRow(string $key, array $value): Detai
                 ->class('yii-debug-queue-tree-meta')
                 ->content(sprintf('(%d)', count($value)))
                 ->render();
+
             $summaryHtml = "{$keyHtml}{$typeHtml}{$metaHtml}";
         }
 
@@ -228,23 +213,7 @@ private static function renderAvatar(JobRecord $record): Span
             ->addAriaAttribute('hidden', 'true')
             ->addAttribute('style', '--queue-hue: ' . Avatar::hueFor($record->jobClass))
             ->class('yii-debug-queue-avatar')
-            ->content(self::initialFor($record->jobClass));
-    }
-
-    /**
-     * Renders the driver pill (`Sync` / `Database` / `Redis` / `AMQP` / ...).
-     *
-     * Async drivers carry a different visual tone via the `is-async` modifier, so the developer can spot at a glance
-     * which jobs ran in-process.
-     */
-    private static function renderDriverPill(JobRecord $record): Span
-    {
-        $modifier = $record->isAsync ? 'is-async' : 'is-sync';
-
-        return Span::tag()
-            ->class("yii-debug-queue-driver yii-debug-queue-driver-{$modifier}")
-            ->title($record->driverClass !== '' ? $record->driverClass : 'Unknown driver')
-            ->content($record->driverName);
+            ->content(Avatar::initial(Fqcn::shortName($record->jobClass)));
     }
 
     /**
@@ -300,10 +269,10 @@ private static function renderHead(JobRecord $record): Header
                 ->content("{$namespace}\\");
         }
 
-        $pills = [self::renderStatusPill($record)];
+        $pills = [QueuePill::status($record)];
 
         if ($record->driverName !== '') {
-            $pills[] = self::renderDriverPill($record);
+            $pills[] = QueuePill::driver($record);
         }
 
         $pills[] = Span::tag()
@@ -408,19 +377,6 @@ private static function renderScalarRow(string $key, string $type, string $value
             );
     }
 
-    /**
-     * Renders the status pill (`Queued` / `Done` / `Failed`).
-     */
-    private static function renderStatusPill(JobRecord $record): Span
-    {
-        $variant = JobRecord::EVENT_VARIANTS[$record->eventType]['variant'] ?? 'queued';
-        $label = JobRecord::EVENT_VARIANTS[$record->eventType]['label'] ?? 'Queued';
-
-        return Span::tag()
-            ->class("yii-debug-queue-status yii-debug-queue-status-{$variant}")
-            ->content($label);
-    }
-
     /**
      * Renders one string tree row, truncating to {@see self::STRING_PREVIEW_LIMIT} characters with the full value
      * preserved in a `title` tooltip.
diff --git a/src/Panel/Queue/QueueGridRenderer.php b/src/Panel/Queue/QueueGridRenderer.php
index de57944..b406fd3 100644
--- a/src/Panel/Queue/QueueGridRenderer.php
+++ b/src/Panel/Queue/QueueGridRenderer.php
@@ -4,16 +4,12 @@
 
 namespace PHPForge\Debug\Panel\Queue;
 
-use PHPForge\Debug\Helper\Fqcn;
+use PHPForge\Debug\Helper\{Format, Fqcn};
 use UIAwesome\Html\Flow\Div;
 use UIAwesome\Html\Palpable\A;
 use UIAwesome\Html\Phrasing\{Span, Strong};
 
-use function abs;
-use function date;
 use function intval;
-use function number_format;
-use function sprintf;
 
 /**
  * Renders the column cells for the Queue panel grid view.
@@ -53,13 +49,7 @@ public static function renderDriverCell(JobRecord $record): string
             return '';
         }
 
-        $modifier = $record->isAsync ? 'is-async' : 'is-sync';
-
-        return Span::tag()
-            ->class("yii-debug-queue-driver yii-debug-queue-driver-{$modifier}")
-            ->title($record->driverClass !== '' ? $record->driverClass : 'Unknown driver')
-            ->content($record->driverName)
-            ->render();
+        return QueuePill::driver($record)->render();
     }
 
     /**
@@ -72,7 +62,7 @@ public static function renderDurationCell(JobRecord $record): string
             return '—';
         }
 
-        return number_format($record->duration * 1000, 1) . ' ms';
+        return Format::milliseconds($record->duration, 1);
     }
 
     /**
@@ -130,13 +120,7 @@ public static function renderJobCell(JobRecord $record, string $href): string
      */
     public static function renderStatusCell(JobRecord $record): string
     {
-        $variant = JobRecord::EVENT_VARIANTS[$record->eventType]['variant'] ?? 'queued';
-        $label = JobRecord::EVENT_VARIANTS[$record->eventType]['label'] ?? 'Queued';
-
-        return Span::tag()
-            ->class("yii-debug-queue-status yii-debug-queue-status-{$variant}")
-            ->content($label)
-            ->render();
+        return QueuePill::status($record)->render();
     }
 
     /**
@@ -144,10 +128,7 @@ public static function renderStatusCell(JobRecord $record): string
      */
     public static function renderTimeCell(JobRecord $record): string
     {
-        $seconds = intval($record->time);
-        $milliseconds = abs(intval($record->time * 1000) % 1000);
-
-        return date('H:i:s.', $seconds) . sprintf('%03d', $milliseconds);
+        return Format::timeOfDay(intval($record->time * 1000));
     }
 
     /**
diff --git a/src/Panel/Queue/QueuePill.php b/src/Panel/Queue/QueuePill.php
new file mode 100644
index 0000000..6a58672
--- /dev/null
+++ b/src/Panel/Queue/QueuePill.php
@@ -0,0 +1,49 @@
+isAsync ? 'is-async' : 'is-sync';
+
+        return Span::tag()
+            ->class("yii-debug-queue-driver yii-debug-queue-driver-{$modifier}")
+            ->title($record->driverClass !== '' ? $record->driverClass : 'Unknown driver')
+            ->content($record->driverName);
+    }
+
+    /**
+     * Builds the lifecycle-status pill from {@see JobRecord::EVENT_VARIANTS}, falling back to the queued state for
+     * unknown event types.
+     *
+     * @param JobRecord $record Typed queue event record.
+     *
+     * @return Span Status pill element.
+     */
+    public static function status(JobRecord $record): Span
+    {
+        $variant = JobRecord::EVENT_VARIANTS[$record->eventType]['variant'] ?? 'queued';
+        $label = JobRecord::EVENT_VARIANTS[$record->eventType]['label'] ?? 'Queued';
+
+        return Span::tag()
+            ->class("yii-debug-queue-status yii-debug-queue-status-{$variant}")
+            ->content($label);
+    }
+}
diff --git a/src/Panel/Queue/QueueSnapshot.php b/src/Panel/Queue/QueueSnapshot.php
index 38d6179..b827037 100644
--- a/src/Panel/Queue/QueueSnapshot.php
+++ b/src/Panel/Queue/QueueSnapshot.php
@@ -47,16 +47,11 @@ public function entries(): array
 
     public static function fromArray(mixed $data, string $path): self
     {
-        $payload = Payload::object($data, $path)
-            ->shape(['entries']);
-
-        $entries = [];
-
-        foreach ($payload->list('entries') as $index => $entry) {
-            $entries[] = JobRecord::fromArray($entry, "{$path}.entries[{$index}]");
-        }
-
-        return new self($entries);
+        return new self(
+            Payload::object($data, $path)
+                ->shape(['entries'])
+                ->mapList('entries', JobRecord::fromArray(...)),
+        );
     }
 
     /**
diff --git a/src/Panel/Request/RequestDiagnosticLedger.php b/src/Panel/Request/RequestDiagnosticLedger.php
new file mode 100644
index 0000000..7f44ca4
--- /dev/null
+++ b/src/Panel/Request/RequestDiagnosticLedger.php
@@ -0,0 +1,52 @@
+class("yii-debug-diagnostic-ledger {$modifier}")
+            ->html(...$rows)
+            ->render();
+    }
+
+    /**
+     * Builds one filterable ledger row from already-escaped term and description markup.
+     *
+     * @param string $term Escaped entry name.
+     * @param string $description Rendered entry value.
+     * @param string $modifier Extra CSS classes appended after the shared row class, or `''` to add none.
+     *
+     * @return Div Ledger row element.
+     */
+    public static function row(string $term, string $description, string $modifier = ''): Div
+    {
+        $class = 'yii-debug-diagnostic-row';
+
+        return Div::tag()
+            ->addDataAttribute('yii-debug-filter-row', true)
+            ->class($modifier === '' ? $class : "{$class} {$modifier}")
+            ->html(
+                Dt::tag()->html($term),
+                Dd::tag()->html($description),
+            );
+    }
+}
diff --git a/src/Panel/Request/RequestHeadersRenderer.php b/src/Panel/Request/RequestHeadersRenderer.php
index 7e3952e..164ae89 100644
--- a/src/Panel/Request/RequestHeadersRenderer.php
+++ b/src/Panel/Request/RequestHeadersRenderer.php
@@ -7,12 +7,12 @@
 use UIAwesome\Html\Flow\{Div, P};
 use UIAwesome\Html\Form\InputSearch;
 use UIAwesome\Html\Heading\{H2, H3};
-use UIAwesome\Html\List\{Dd, Dl, Dt};
 use UIAwesome\Html\Phrasing\Span;
 use UIAwesome\Html\Root\Header;
 use UIAwesome\Html\Sectioning\Section;
 
 use function count;
+use function is_int;
 
 /**
  * Renders request and response headers as a directional HTTP exchange ledger.
@@ -154,22 +154,13 @@ private static function renderLedger(array $entries, bool $response): string
             $label = is_int($name)
                 ? ($response ? 'Raw response line ' : 'Raw header line ') . $name
                 : $name;
-            $class = is_int($name)
-                ? 'yii-debug-diagnostic-row yii-debug-header-raw-row'
-                : 'yii-debug-diagnostic-row';
-
-            $rows[] = Div::tag()
-                ->addDataAttribute('yii-debug-filter-row', true)
-                ->class($class)
-                ->html(
-                    Dt::tag()->html(RequestDiagnosticValueRenderer::escape($label)),
-                    Dd::tag()->html(RequestDiagnosticValueRenderer::header($value)),
-                );
+            $rows[] = RequestDiagnosticLedger::row(
+                RequestDiagnosticValueRenderer::escape($label),
+                RequestDiagnosticValueRenderer::header($value),
+                is_int($name) ? 'yii-debug-header-raw-row' : '',
+            );
         }
 
-        return Dl::tag()
-            ->class('yii-debug-diagnostic-ledger yii-debug-header-ledger')
-            ->html(...$rows)
-            ->render();
+        return RequestDiagnosticLedger::render('yii-debug-header-ledger', ...$rows);
     }
 }
diff --git a/src/Panel/Request/RequestRenderer.php b/src/Panel/Request/RequestRenderer.php
index 38b92ce..b1af082 100644
--- a/src/Panel/Request/RequestRenderer.php
+++ b/src/Panel/Request/RequestRenderer.php
@@ -138,9 +138,7 @@ private static function renderOverview(RequestHero $hero, CurrentRouteView $curr
         $identity = [];
 
         if ($method !== '') {
-            $identity[] = Span::tag()
-                ->class('yii-debug-request-hero-method yii-debug-verb-' . Vocabulary::verb($method))
-                ->content($method);
+            $identity[] = RequestSectionRenderer::renderMethodPill($method);
         }
 
         $identity[] = Span::tag()
@@ -212,9 +210,18 @@ private static function renderOverview(RequestHero $hero, CurrentRouteView $curr
                 Dl::tag()
                     ->class('yii-debug-request-overview-metrics')
                     ->html(
-                        self::renderMetric('Route', $route !== '' ? $route : 'Unresolved'),
-                        self::renderMetric('Action', $action !== '' ? $action : 'Unavailable'),
-                        self::renderMetric('Duration', $hero->getDurationMs() !== '' ? $hero->getDurationMs() : 'Unavailable'),
+                        self::renderMetric(
+                            'Route',
+                            $route !== '' ? $route : 'Unresolved',
+                        ),
+                        self::renderMetric(
+                            'Action',
+                            $action !== '' ? $action : 'Unavailable',
+                        ),
+                        self::renderMetric(
+                            'Duration',
+                            $hero->getDurationMs() !== '' ? $hero->getDurationMs() : 'Unavailable',
+                        ),
                     ),
                 Div::tag()
                     ->class('yii-debug-request-overview-meta')
@@ -305,7 +312,10 @@ private static function renderTabs(RequestView $view, RequestRoutingView $routin
         $server = self::tab($view->tabs, 'server');
 
         if ($server !== null) {
-            $tabs[] = ['label' => 'Server', 'content' => self::renderServer($server, $view)];
+            $tabs[] = [
+                'label' => 'Server',
+                'content' => self::renderServer($server, $view),
+            ];
         }
 
         return Div::tag()
diff --git a/src/Panel/Request/RequestRoutesRenderer.php b/src/Panel/Request/RequestRoutesRenderer.php
index e5b8239..61ea015 100644
--- a/src/Panel/Request/RequestRoutesRenderer.php
+++ b/src/Panel/Request/RequestRoutesRenderer.php
@@ -4,7 +4,7 @@
 
 namespace PHPForge\Debug\Panel\Request;
 
-use PHPForge\Debug\Helper\{Disclosure, EmptyState, Vocabulary};
+use PHPForge\Debug\Helper\{Badge, Disclosure, EmptyState, Table, Vocabulary};
 use PHPForge\Debug\Panel\Request\Routing\{
     CurrentRouteView,
     RouteBadge,
@@ -12,6 +12,7 @@
     RouteInventoryView,
     RouteTraceRow,
 };
+use PHPForge\Debug\View\Grid\RowClass;
 use UIAwesome\Html\Flow\{Div, P};
 use UIAwesome\Html\Form\InputSearch;
 use UIAwesome\Html\Heading\H2;
@@ -19,7 +20,7 @@
 use UIAwesome\Html\List\{Dd, Dl, Dt, Li, Ol};
 use UIAwesome\Html\Phrasing\{Code, Span};
 use UIAwesome\Html\Root\Header;
-use UIAwesome\Html\Table\{Table, Tbody, Td, Th, Thead, Tr};
+use UIAwesome\Html\Table\{Td, Tr};
 
 use function count;
 use function in_array;
@@ -27,8 +28,6 @@
 
 /**
  * Renders a common route inventory with framework-specific metadata inside row disclosures.
- *
- * @internal
  */
 final class RequestRoutesRenderer
 {
@@ -82,9 +81,7 @@ private static function renderBadge(RouteBadge $badge): Span
     {
         $variant = in_array($badge->variant, self::BADGE_VARIANTS, true) ? $badge->variant : 'muted';
 
-        return Span::tag()
-            ->class("yii-debug-badge yii-debug-badge-{$variant}")
-            ->content($badge->label);
+        return Badge::render($badge->label, $variant);
     }
 
     private static function renderDetails(RouteDefinition $route): Dl
@@ -142,9 +139,7 @@ private static function renderInventory(array $routes, CurrentRouteView $current
             $contents = [Code::tag()->content($identity)];
 
             if ($matched) {
-                $contents[] = Span::tag()
-                    ->class('yii-debug-badge yii-debug-badge-success yii-debug-route-match')
-                    ->content('Matched');
+                $contents[] = Badge::render('Matched', 'success', 'yii-debug-route-match');
             }
 
             $entry = Details::tag()
@@ -152,19 +147,29 @@ private static function renderInventory(array $routes, CurrentRouteView $current
                 ->addDataAttribute('yii-debug-filter-details', true)
                 ->addDataAttribute('yii-debug-filter-default-open', 'false')
                 ->html(
-                    Summary::tag()->class('yii-debug-route-summary')->html(
-                        Span::tag()->class('yii-debug-route-order')->content((string) ($index + 1)),
-                        self::renderMethodChips($route->getMethods()),
-                        Code::tag()->class('yii-debug-route-pattern')->content($route->getPattern() !== '' ? $route->getPattern() : '—'),
-                        Span::tag()->class('yii-debug-route-identity')->html(...$contents),
-                        Disclosure::hint(),
-                    ),
+                    Summary::tag()
+                        ->class('yii-debug-route-summary')
+                        ->html(
+                            Span::tag()
+                                ->class('yii-debug-route-order')
+                                ->content((string) ($index + 1)),
+                            self::renderMethodChips($route->getMethods()),
+                            Code::tag()
+                                ->class('yii-debug-route-pattern')
+                                ->content($route->getPattern() !== '' ? $route->getPattern() : '—'),
+                            Span::tag()
+                                ->class('yii-debug-route-identity')
+                                ->html(...$contents),
+                            Disclosure::hint(),
+                        ),
                     self::renderDetails($route),
                 );
-            $row = Li::tag()->addDataAttribute('yii-debug-filter-row', true)->html($entry);
+            $row = Li::tag()
+                ->addDataAttribute('yii-debug-filter-row', true)
+                ->html($entry);
 
             if ($matched) {
-                $row = $row->class('yii-debug-row-success')->addDataAttribute('yii-debug-route-match', true);
+                $row = $row->attributes(RowClass::for('success'))->addDataAttribute('yii-debug-route-match', true);
             }
 
             $rows[] = $row;
@@ -196,12 +201,8 @@ private static function renderInventoryContext(RouteInventoryView $inventory): s
     {
         $source = rtrim($inventory->getSource(), " .\t\n\r\0\x0B");
 
-        $message = $source === '' ? 'Configuration source unavailable.' : "Source: {$source}.";
-
-        if ($inventory->isLive()) {
-            $message .= ' Live configuration may differ from this capture.';
-        }
-
+        $provenance = $source === '' ? 'Configuration source unavailable.' : "Source: {$source}.";
+        $message = "{$provenance} Live configuration may differ from this capture.";
         $badges = [];
 
         foreach ($inventory->getBadges() as $badge) {
@@ -280,13 +281,9 @@ private static function renderTraceTable(array $trace): string
         $rows = [];
 
         foreach ($trace as $index => $entry) {
-            $result = Span::tag()
-                ->class(
-                    $entry->matched
-                        ? 'yii-debug-badge yii-debug-badge-success'
-                        : 'yii-debug-badge yii-debug-badge-muted',
-                )
-                ->content($entry->matched ? 'Matched' : 'Not matched');
+            $result = $entry->matched
+                ? Badge::render('Matched', 'success')
+                : Badge::render('Not matched', 'muted');
             $row = Tr::tag()
                 ->html(
                     Td::tag()->content((string) ($index + 1)),
@@ -296,31 +293,17 @@ private static function renderTraceTable(array $trace): string
                 );
 
             if ($entry->matched) {
-                $row = $row->class('yii-debug-row-success');
+                $row = $row->attributes(RowClass::for('success'));
             }
 
             $rows[] = $row;
         }
 
-        return Div::tag()
-            ->class('yii-debug-table-wrap yii-debug-route-trace-wrap')
-            ->html(
-                Table::tag()
-                    ->class('yii-debug-table yii-debug-route-trace')
-                    ->html(
-                        Thead::tag()
-                            ->html(
-                                Tr::tag()
-                                    ->html(
-                                        Th::tag()->scope('col')->content('#'),
-                                        Th::tag()->scope('col')->content('Rule'),
-                                        Th::tag()->scope('col')->content('Parent'),
-                                        Th::tag()->scope('col')->content('Result'),
-                                    ),
-                            ),
-                        Tbody::tag()->html(...$rows),
-                    ),
-            )
-            ->render();
+        return Table::render(
+            ['#', 'Rule', 'Parent', 'Result'],
+            $rows,
+            'yii-debug-table yii-debug-route-trace',
+            'yii-debug-table-wrap yii-debug-route-trace-wrap',
+        );
     }
 }
diff --git a/src/Panel/Request/RequestSectionRenderer.php b/src/Panel/Request/RequestSectionRenderer.php
index 4d379fc..3658ea6 100644
--- a/src/Panel/Request/RequestSectionRenderer.php
+++ b/src/Panel/Request/RequestSectionRenderer.php
@@ -4,18 +4,13 @@
 
 namespace PHPForge\Debug\Panel\Request;
 
-use PHPForge\Debug\Helper\{Disclosure, Dump, Tabs, Vocabulary};
+use PHPForge\Debug\Helper\{Disclosure, Dump, Table, Tabs, Vocabulary};
 use UIAwesome\Html\Flow\{Div, P};
 use UIAwesome\Html\Form\InputSearch;
 use UIAwesome\Html\Heading\H2;
 use UIAwesome\Html\Phrasing\Span;
 use UIAwesome\Html\Root\Header;
-use UIAwesome\Html\Table\{Table, Tbody, Td, Th, Thead, Tr};
-
-use function htmlspecialchars;
-
-use const ENT_QUOTES;
-use const ENT_SUBSTITUTE;
+use UIAwesome\Html\Table\{Td, Th, Tr};
 
 /**
  * Renders the Request panel detail view.
@@ -59,9 +54,7 @@ public static function renderHero(RequestHero $hero): string
         $line = [];
 
         if ($hero->getMethod() !== '') {
-            $line[] = Span::tag()
-                ->class('yii-debug-request-hero-method yii-debug-verb-' . Vocabulary::verb($hero->getMethod()))
-                ->content($hero->getMethod());
+            $line[] = self::renderMethodPill($hero->getMethod());
         }
 
         $line[] = Span::tag()
@@ -101,12 +94,30 @@ public static function renderHero(RequestHero $hero): string
         return Header::tag()
             ->class('yii-debug-request-hero')
             ->html(
-                Div::tag()->class('yii-debug-request-hero-line')->html(...$line),
-                Div::tag()->class('yii-debug-request-hero-meta')->html(...$meta),
+                Div::tag()
+                    ->class('yii-debug-request-hero-line')
+                    ->html(...$line),
+                Div::tag()
+                    ->class('yii-debug-request-hero-meta')
+                    ->html(...$meta),
             )
             ->render();
     }
 
+    /**
+     * Renders the HTTP method pill carrying the shared semantic verb hue.
+     *
+     * @param string $method Captured HTTP method.
+     *
+     * @return Span Method pill element.
+     */
+    public static function renderMethodPill(string $method): Span
+    {
+        return Span::tag()
+            ->class('yii-debug-request-hero-method yii-debug-verb-' . Vocabulary::verb($method))
+            ->content($method);
+    }
+
     /**
      * Renders a single name/value section as `
` + ``, or as a collapsed disclosure when the section * has no entries. @@ -157,15 +168,15 @@ private static function renderFilter(RequestSection $section): InputSearch|null } /** - * Renders one row of the section table: name in the ` + +
`, value dumped via {@see Dump::asString()} in the - * `` with `htmlspecialchars` (`ENT_QUOTES | ENT_SUBSTITUTE`) escaping, so invalid byte sequences degrade to + * Renders one row of the section table: name in the ``, value dumped via {@see Dump::asString()} in the `` + * and escaped through {@see RequestDiagnosticValueRenderer::escape()}, so invalid byte sequences degrade to * substitution characters instead of blanking the row. */ private static function renderRow(int|string $name, mixed $value): Tr { $valueText = Dump::asString($value); - $escaped = htmlspecialchars($valueText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', true); + $escaped = RequestDiagnosticValueRenderer::escape($valueText); return Tr::tag() ->html( @@ -219,24 +230,8 @@ private static function renderSectionTable(RequestSection $section): string return $wrap ->html( - Table::tag() - ->class('yii-debug-table yii-debug-table-mono') - ->style(['table-layout' => 'fixed']) - ->html( - Thead::tag() - ->html( - Tr::tag() - ->html( - Th::tag() - ->scope('col') - ->content('Name'), - Th::tag() - ->scope('col') - ->content('Value'), - ), - ), - Tbody::tag()->html(...$rows), - ), + Table::build(['Name', 'Value'], $rows, 'yii-debug-table yii-debug-table-mono') + ->style(['table-layout' => 'fixed']), ) ->render(); } diff --git a/src/Panel/Request/RequestServerRenderer.php b/src/Panel/Request/RequestServerRenderer.php index f7e49d4..ea3d140 100644 --- a/src/Panel/Request/RequestServerRenderer.php +++ b/src/Panel/Request/RequestServerRenderer.php @@ -9,7 +9,6 @@ use UIAwesome\Html\Form\InputSearch; use UIAwesome\Html\Heading\H2; use UIAwesome\Html\Interactive\{Details, Summary}; -use UIAwesome\Html\List\{Dd, Dl, Dt}; use UIAwesome\Html\Phrasing\Span; use UIAwesome\Html\Root\Header; use UIAwesome\Html\Sectioning\Section; @@ -59,7 +58,7 @@ private static function additionalEntries(array $entries, RequestView $view): ar $target = $url === false ? '' : ($url['path'] ?? ''); if ($url !== false && isset($url['query'])) { - $target .= '?' . $query; + $target .= "?{$query}"; } $shown = [ @@ -130,8 +129,12 @@ private static function renderGroup(ServerVariableGroup $group): string Span::tag() ->class('yii-debug-server-group-identity') ->html( - Span::tag()->class('yii-debug-server-group-title')->content($group->label), - Span::tag()->class('yii-debug-server-group-count')->content((string) count($group->entries)), + Span::tag() + ->class('yii-debug-server-group-title') + ->content($group->label), + Span::tag() + ->class('yii-debug-server-group-count') + ->content((string) count($group->entries)), ), Disclosure::hint(), ), @@ -174,19 +177,14 @@ private static function renderLedger(array $entries): string $rows = []; foreach ($entries as $name => $value) { - $rows[] = Div::tag() - ->addDataAttribute('yii-debug-filter-row', true) - ->class('yii-debug-diagnostic-row yii-debug-server-row') - ->html( - Dt::tag()->html(RequestDiagnosticValueRenderer::escape((string) $name)), - Dd::tag()->html(RequestDiagnosticValueRenderer::value($value)), - ); + $rows[] = RequestDiagnosticLedger::row( + RequestDiagnosticValueRenderer::escape((string) $name), + RequestDiagnosticValueRenderer::value($value), + 'yii-debug-server-row', + ); } - return Dl::tag() - ->class('yii-debug-diagnostic-ledger yii-debug-server-ledger') - ->html(...$rows) - ->render(); + return RequestDiagnosticLedger::render('yii-debug-server-ledger', ...$rows); } /** @@ -198,11 +196,13 @@ private static function renderView(array $entries, array $additional): string $groups = ''; foreach (ServerVariableGrouper::group($additional) as $group) { - $groups .= self::renderGroup(new ServerVariableGroup( - $group->id, - $group->id === 'header-mirrors' ? 'Additional header variables' : $group->label, - $group->entries, - )); + $groups .= self::renderGroup( + new ServerVariableGroup( + $group->id, + $group->id === 'header-mirrors' ? 'Additional header variables' : $group->label, + $group->entries, + ), + ); } if ($groups === '') { @@ -222,13 +222,17 @@ private static function renderView(array $entries, array $additional): string Div::tag() ->class('yii-debug-diagnostic-heading-copy') ->html( - H2::tag()->id('yii-debug-server-environment-title')->content('Server details'), + H2::tag() + ->id('yii-debug-server-environment-title') + ->content('Server details'), Span::tag() ->class('yii-debug-diagnostic-total') ->content(count($additional) . ' additional / ' . count($entries) . ' captured'), ), ), - Div::tag()->class('yii-debug-server-additional')->html($groups), + Div::tag() + ->class('yii-debug-server-additional') + ->html($groups), $entries === [] ? '' : self::renderGroup(new ServerVariableGroup( 'raw', 'Raw server variables', diff --git a/src/Panel/Request/RequestToolbarItemFactory.php b/src/Panel/Request/RequestToolbarItemFactory.php index 30f50f0..e6b4ff5 100644 --- a/src/Panel/Request/RequestToolbarItemFactory.php +++ b/src/Panel/Request/RequestToolbarItemFactory.php @@ -22,22 +22,18 @@ public static function create(string $route, int $statusCode, string $statusText $items = []; if ($route !== '') { - $items[] = new ToolbarItem( - value: $route, - status: 'default', - title: "Resolved route: {$route}", - id: 'route', - ); + $items[] = ToolbarItem::create($route) + ->withStatus('default') + ->withTitle("Resolved route: {$route}") + ->withId('route'); } $statusClass = Vocabulary::statusClass($statusCode); - $items[] = new ToolbarItem( - value: (string) $statusCode, - status: $statusClass === 'none' ? 'default' : "status-{$statusClass}", - title: trim("Status code: {$statusCode} {$statusText}"), - id: 'status', - ); + $items[] = ToolbarItem::create((string) $statusCode) + ->withStatus($statusClass === 'none' ? 'default' : "status-{$statusClass}") + ->withTitle(trim("Status code: {$statusCode} {$statusText}")) + ->withId('status'); return $items; } diff --git a/src/Panel/Request/Routing/RouteInventoryView.php b/src/Panel/Request/Routing/RouteInventoryView.php index 2804f49..3560594 100644 --- a/src/Panel/Request/Routing/RouteInventoryView.php +++ b/src/Panel/Request/Routing/RouteInventoryView.php @@ -10,20 +10,26 @@ final class RouteInventoryView { /** + * Route badges derived from the inventory. + * * @var list */ private array $badges = []; - + /** + * Error message generated while building the inventory, or `null` when no error occurred. + */ private string|null $error = null; - - private bool $live = true; - - private string $source = 'Current application configuration'; - /** - * @param list $routes + * Source label describing where the inventory data came from. */ - public function __construct(private array $routes) {} + private string $source = 'Current application configuration'; + + public function __construct( + /** + * @var list + */ + private array $routes, + ) {} /** * @param list $routes @@ -59,11 +65,6 @@ public function getSource(): string return $this->source; } - public function isLive(): bool - { - return $this->live; - } - /** * @param list $badges */ @@ -83,14 +84,6 @@ public function withError(string|null $error): self return $clone; } - public function withLive(bool $live): self - { - $clone = clone $this; - $clone->live = $live; - - return $clone; - } - public function withSource(string $source): self { $clone = clone $this; diff --git a/src/Panel/Router/CurrentRouteLogRow.php b/src/Panel/Router/CurrentRouteLogRow.php index 396d872..7414f70 100644 --- a/src/Panel/Router/CurrentRouteLogRow.php +++ b/src/Panel/Router/CurrentRouteLogRow.php @@ -4,6 +4,7 @@ namespace PHPForge\Debug\Panel\Router; +use PHPForge\Debug\Helper\Coerce; use PHPForge\Debug\Storage\{PanelRow, Payload}; use function is_array; @@ -68,7 +69,7 @@ public static function fromLogMessage(mixed $message): self|null return new self( $message['rule'], - is_string($parent) ? $parent : '', + Coerce::string($parent), $message['match'], ); } diff --git a/src/Panel/Router/RouterSectionRenderer.php b/src/Panel/Router/RouterSectionRenderer.php index 7585516..41eff13 100644 --- a/src/Panel/Router/RouterSectionRenderer.php +++ b/src/Panel/Router/RouterSectionRenderer.php @@ -4,13 +4,14 @@ namespace PHPForge\Debug\Panel\Router; -use PHPForge\Debug\Helper\Tabs; +use PHPForge\Debug\Helper\{Badge, Table, Tabs}; +use PHPForge\Debug\View\Grid\RowClass; use UIAwesome\Html\Flow\{Div, P}; use UIAwesome\Html\Heading\H2; use UIAwesome\Html\List\{Dd, Dl, Dt}; -use UIAwesome\Html\Phrasing\{Code, Span}; +use UIAwesome\Html\Phrasing\Code; use UIAwesome\Html\Root\Header; -use UIAwesome\Html\Table\{Table, Tbody, Td, Th, Thead, Tr}; +use UIAwesome\Html\Table\{Td, Tr}; use function count; use function sprintf; @@ -74,47 +75,7 @@ private static function renderActionRoutesPanel(array $actionRows): string ); } - return Div::tag() - ->class('yii-debug-table-wrap') - ->html( - Table::tag() - ->class('yii-debug-table') - ->html( - Thead::tag() - ->html( - Tr::tag() - ->html( - Th::tag() - ->scope('col') - ->content('#'), - Th::tag() - ->scope('col') - ->content('Action'), - Th::tag() - ->scope('col') - ->content('Route'), - Th::tag() - ->scope('col') - ->content('First Matching Rule'), - Th::tag() - ->scope('col') - ->content('Rules Tested'), - ), - ), - Tbody::tag()->html(...$rows), - ), - ) - ->render(); - } - - /** - * Renders one read-only badge chip for the flags strip. - */ - private static function renderBadgeChip(string $label, string $variant): Span - { - return Span::tag() - ->class("yii-debug-badge yii-debug-badge-{$variant}") - ->content($label); + return Table::render(['#', 'Action', 'Route', 'First Matching Rule', 'Rules Tested'], $rows); } /** @@ -159,6 +120,7 @@ private static function renderCurrentRoutePanel(RouterCurrentView $current): str $summary = self::renderRouteSummary($current); $callout = self::renderCalloutBlock($current); $logs = self::renderLogsTable($current); + $body = "{$summary}{$heading}{$callout}{$logs}"; return $body === '' @@ -183,7 +145,7 @@ private static function renderFlagsStrip(array $badges): string $chips = []; foreach ($badges as $badge) { - $chips[] = self::renderBadgeChip($badge['label'], $badge['variant']); + $chips[] = Badge::render($badge['label'], $badge['variant']); } return Header::tag() @@ -207,8 +169,8 @@ private static function renderLogsTable(RouterCurrentView $current): string foreach ($current->logs as $i => $row) { $result = $row->match - ? self::renderBadgeChip('Matched', 'success') - : self::renderBadgeChip('Not matched', 'muted'); + ? Badge::render('Matched', 'success') + : Badge::render('Not matched', 'muted'); $tr = Tr::tag() ->html( @@ -219,40 +181,13 @@ private static function renderLogsTable(RouterCurrentView $current): string ); if ($row->match) { - $tr = $tr->class('yii-debug-row-success'); + $tr = $tr->attributes(RowClass::for('success')); } $rows[] = $tr; } - return Div::tag() - ->class('yii-debug-table-wrap') - ->html( - Table::tag() - ->class('yii-debug-table') - ->html( - Thead::tag() - ->html( - Tr::tag() - ->html( - Th::tag() - ->scope('col') - ->content('#'), - Th::tag() - ->scope('col') - ->content('Rule'), - Th::tag() - ->scope('col') - ->content('Parent'), - Th::tag() - ->scope('col') - ->content('Result'), - ), - ), - Tbody::tag()->html(...$rows), - ), - ) - ->render(); + return Table::render(['#', 'Rule', 'Parent', 'Result'], $rows); } /** @@ -283,43 +218,7 @@ private static function renderRouterRulesPanel(array $ruleRows): string ); } - return Div::tag() - ->class('yii-debug-table-wrap') - ->html( - Table::tag() - ->class('yii-debug-table') - ->html( - Thead::tag() - ->html( - Tr::tag() - ->html( - Th::tag() - ->scope('col') - ->content('#'), - Th::tag() - ->scope('col') - ->content('Rule'), - Th::tag() - ->scope('col') - ->content('Target'), - Th::tag() - ->scope('col') - ->content('Verb'), - Th::tag() - ->scope('col') - ->content('Suffix'), - Th::tag() - ->scope('col') - ->content('Mode'), - Th::tag() - ->scope('col') - ->content('Type'), - ), - ), - Tbody::tag()->html(...$rows), - ), - ) - ->render(); + return Table::render(['#', 'Rule', 'Target', 'Verb', 'Suffix', 'Mode', 'Type'], $rows); } /** diff --git a/src/Panel/Timeline/TimelineMemoryRenderer.php b/src/Panel/Timeline/TimelineMemoryRenderer.php index f281d9c..e37c43e 100644 --- a/src/Panel/Timeline/TimelineMemoryRenderer.php +++ b/src/Panel/Timeline/TimelineMemoryRenderer.php @@ -16,6 +16,11 @@ */ final class TimelineMemoryRenderer { + /** + * Defines gradient opacity stops keyed by percentage. + * + * @var array + */ private const array GRADIENT = [ 10 => 0.18, 60 => 0.45, @@ -108,14 +113,7 @@ private static function number(float|int $value): string */ private static function polygonPoints(array $points, int $width, int $height): string { - $rendered = "0 {$height}"; - - $lastY = $height; - - foreach ($points as [$x, $y]) { - $rendered .= ' ' . self::number($x) . ' ' . self::number($y); - $lastY = $y; - } + [$rendered, $lastY] = self::tracePoints($points, $height); return $rendered . ' ' . self::number($width - 0.001) . ' ' . self::number($lastY) @@ -126,6 +124,21 @@ private static function polygonPoints(array $points, int $width, int $height): s * @param list $points */ private static function polylinePoints(array $points, int $width, int $height): string + { + [$rendered, $lastY] = self::tracePoints($points, $height); + + return $rendered . " {$width} " . self::number($lastY); + } + + /** + * Traces the sampled points from the baseline, returning the point list and the last plotted `y` coordinate the + * polygon and polyline closers extend from. + * + * @param list $points + * + * @return array{0: string, 1: float|int} Rendered point list and the last plotted `y` coordinate. + */ + private static function tracePoints(array $points, int $height): array { $rendered = "0 {$height}"; @@ -136,6 +149,6 @@ private static function polylinePoints(array $points, int $width, int $height): $lastY = $y; } - return $rendered . " {$width} " . self::number($lastY); + return [$rendered, $lastY]; } } diff --git a/src/Panel/User/UserDataNormalizer.php b/src/Panel/User/UserDataNormalizer.php index ef3b59d..015bdb1 100644 --- a/src/Panel/User/UserDataNormalizer.php +++ b/src/Panel/User/UserDataNormalizer.php @@ -4,16 +4,12 @@ namespace PHPForge\Debug\Panel\User; -use PHPForge\Debug\Helper\Icon; -use PHPForge\Debug\Helper\SensitiveDataRedactor; +use PHPForge\Debug\Helper\{Avatar, Format, Icon, SensitiveDataRedactor}; use function ctype_digit; use function date; use function in_array; -use function intdiv; use function is_array; -use function mb_strtoupper; -use function mb_substr; use function preg_match; use function str_ends_with; use function str_replace; @@ -150,9 +146,7 @@ private static function buildHero(array $identity): UserIdentityHero ? self::resolveStatus($rawStatus) : ['', 'muted']; - $monogramSource = $username !== '' ? $username : ($email !== '' ? $email : '?'); - - $monogram = mb_strtoupper(mb_substr($monogramSource, 0, 1)); + $monogram = Avatar::initial($username !== '' ? $username : $email); return new UserIdentityHero( username: $username !== '' ? $username : 'Unknown user', @@ -277,25 +271,7 @@ private static function humanTime(string $value): array $diff = time() - $unix; $absolute = date('M j, Y · H:i', $unix); - if ($diff < 60) { - $relative = 'just now'; - } elseif ($diff < 3600) { - $minutes = intdiv($diff, 60); - - $relative = "{$minutes} min ago"; - } elseif ($diff < 86400) { - $hours = intdiv($diff, 3600); - - $relative = "{$hours} h ago"; - } elseif ($diff < 2592000) { - $days = intdiv($diff, 86400); - - $relative = "{$days} d ago"; - } else { - $relative = $absolute; - } - - return [$relative, $absolute]; + return [Format::relativeTime($diff, $absolute), $absolute]; } /** diff --git a/src/Panel/User/UserRbacRow.php b/src/Panel/User/UserRbacRow.php index 07bc009..acee2db 100644 --- a/src/Panel/User/UserRbacRow.php +++ b/src/Panel/User/UserRbacRow.php @@ -4,8 +4,7 @@ namespace PHPForge\Debug\Panel\User; -use function is_int; -use function is_string; +use PHPForge\Debug\Helper\Coerce; /** * Represents one RBAC item row (role or permission) in the User panel detail view. @@ -45,12 +44,12 @@ public static function fromArray(array $row): self $updatedAt = $row['updatedAt'] ?? null; return new self( - name: is_string($name) ? $name : '', - description: is_string($description) ? $description : '', - ruleName: is_string($ruleName) ? $ruleName : '', - data: is_string($data) ? $data : '', - createdAt: is_int($createdAt) ? $createdAt : (is_numeric($createdAt) ? (int) $createdAt : null), - updatedAt: is_int($updatedAt) ? $updatedAt : (is_numeric($updatedAt) ? (int) $updatedAt : null), + name: Coerce::string($name), + description: Coerce::string($description), + ruleName: Coerce::string($ruleName), + data: Coerce::string($data), + createdAt: Coerce::intOrNull($createdAt), + updatedAt: Coerce::intOrNull($updatedAt), ); } } diff --git a/src/Panel/Vite/ViteComponent.php b/src/Panel/Vite/ViteComponent.php index 7a1c087..ddd08da 100644 --- a/src/Panel/Vite/ViteComponent.php +++ b/src/Panel/Vite/ViteComponent.php @@ -9,7 +9,6 @@ use function array_map; use function in_array; -use function is_bool; use function is_string; /** @@ -17,10 +16,25 @@ */ final readonly class ViteComponent implements PanelRow { + /** + * Identifies the legacy Vite integration implementation. + */ public const string IMPLEMENTATION_LEGACY = 'legacy'; + /** + * Identifies the modern Vite integration implementation. + */ public const string IMPLEMENTATION_MODERN = 'modern'; + /** + * Identifies the Vite development mode. + */ public const string MODE_DEVELOPMENT = 'development'; + /** + * Identifies the Vite production mode. + */ public const string MODE_PRODUCTION = 'production'; + /** + * Identifies an unknown Vite mode. + */ public const string MODE_UNKNOWN = 'unknown'; /** @@ -92,12 +106,6 @@ public static function fromArray(mixed $data, string $path): self $entrypoints[] = $entrypoint; } - $chunks = []; - - foreach ($payload->list('chunks') as $index => $chunk) { - $chunks[] = ViteChunk::fromArray($chunk, "{$path}.chunks[{$index}]"); - } - return new self( id: $payload->string('id'), class: $payload->string('class'), @@ -108,9 +116,9 @@ class: $payload->string('class'), baseUrl: $payload->string('baseUrl'), devServerUrl: $payload->nullableString('devServerUrl'), manifestPath: $payload->string('manifestPath'), - includeViteClient: self::nullableBool($payload->raw('includeViteClient'), "{$path}.includeViteClient"), - modulePreload: self::nullableBool($payload->raw('modulePreload'), "{$path}.modulePreload"), - chunks: $chunks, + includeViteClient: $payload->nullableBool('includeViteClient'), + modulePreload: $payload->nullableBool('modulePreload'), + chunks: $payload->mapList('chunks', ViteChunk::fromArray(...)), ); } @@ -134,13 +142,4 @@ public function jsonSerialize(): array 'chunks' => array_map(static fn(ViteChunk $chunk): array => $chunk->jsonSerialize(), $this->chunks), ]; } - - private static function nullableBool(mixed $value, string $path): bool|null - { - if ($value === null || is_bool($value)) { - return $value; - } - - throw HydrationException::at($path, 'a boolean or null'); - } } diff --git a/src/Panel/Vite/ViteSectionRenderer.php b/src/Panel/Vite/ViteSectionRenderer.php index 132f608..d1d548b 100644 --- a/src/Panel/Vite/ViteSectionRenderer.php +++ b/src/Panel/Vite/ViteSectionRenderer.php @@ -4,7 +4,7 @@ namespace PHPForge\Debug\Panel\Vite; -use PHPForge\Debug\Helper\EmptyState; +use PHPForge\Debug\Helper\{Badge, EmptyState, Table}; use PHPForge\Debug\Panel\PanelTitle; use Stringable; use UIAwesome\Html\Flow\{Div, P}; @@ -12,7 +12,7 @@ use UIAwesome\Html\Phrasing\{Span, Strong}; use UIAwesome\Html\Root\Header; use UIAwesome\Html\Sectioning\Section; -use UIAwesome\Html\Table\{Table, Tbody, Td, Th, Thead, Tr}; +use UIAwesome\Html\Table\{Td, Th, Tr}; use function implode; @@ -63,27 +63,29 @@ private static function modeLabel(string $mode): string /** * Renders the production chunk table or the mode-specific explanation for an empty inventory. */ - private static function renderChunks(ViteComponent $component): Stringable + private static function renderChunks(ViteComponent $component): string { $chunks = $component->chunks(); if ($chunks === []) { - return P::tag()->content( - match ($component->mode) { - ViteComponent::MODE_DEVELOPMENT - => 'Development mode resolves entry points through the dev server.', - ViteComponent::MODE_PRODUCTION - => 'The Vite manifest is missing or empty — run the front-end build to populate it.', - default => 'No build chunks were available for inspection.', - }, - ); + return P::tag() + ->content( + match ($component->mode) { + ViteComponent::MODE_DEVELOPMENT + => 'Development mode resolves entry points through the dev server.', + ViteComponent::MODE_PRODUCTION + => 'The Vite manifest is missing or empty — run the front-end build to populate it.', + default => 'No build chunks were available for inspection.', + }, + ) + ->render(); } $rows = []; foreach ($chunks as $index => $chunk) { $entry = $chunk->isEntry - ? Span::tag()->class('yii-debug-badge yii-debug-badge-success')->content('entry') + ? Badge::render('entry', 'success') : Span::tag()->content('—'); $rows[] = Tr::tag()->html( @@ -106,25 +108,7 @@ private static function renderChunks(ViteComponent $component): Stringable ); } - return Div::tag() - ->class('yii-debug-table-wrap') - ->html( - Table::tag() - ->class('yii-debug-table') - ->html( - Thead::tag()->html( - Tr::tag()->html( - Th::tag()->scope('col')->content('#'), - Th::tag()->scope('col')->content('Chunk'), - Th::tag()->scope('col')->content('Output'), - Th::tag()->scope('col')->content('CSS'), - Th::tag()->scope('col')->content('Imports'), - Th::tag()->scope('col')->content('Entry'), - ), - ), - Tbody::tag()->html(...$rows), - ), - ); + return Table::render(['#', 'Chunk', 'Output', 'CSS', 'Imports', 'Entry'], $rows); } /** @@ -132,13 +116,9 @@ private static function renderChunks(ViteComponent $component): Stringable */ private static function renderComponent(ViteComponent $component): string { - $inspectionBadge = Span::tag() - ->class( - $component->inspectionAvailable - ? 'yii-debug-badge yii-debug-badge-success' - : 'yii-debug-badge yii-debug-badge-warning', - ) - ->content($component->inspectionAvailable ? 'Available' : 'Unavailable'); + $inspectionBadge = $component->inspectionAvailable + ? Badge::render('Available', 'success') + : Badge::render('Unavailable', 'warning'); $viteClient = $component->mode === ViteComponent::MODE_PRODUCTION ? 'Not applicable' @@ -162,14 +142,7 @@ private static function renderComponent(ViteComponent $component): string self::renderOverviewRow('Vite client', $viteClient), self::renderOverviewRow('Module preload', $modulePreload), ]; - $content = Div::tag() - ->class('yii-debug-table-wrap') - ->html( - Table::tag() - ->class('yii-debug-table yii-debug-table-mono yii-debug-table-vite-overview') - ->html(Tbody::tag()->html(...$rows)), - ) - ->render(); + $content = Table::render([], $rows, 'yii-debug-table yii-debug-table-mono yii-debug-table-vite-overview'); if ($component->inspectionAvailable === false) { $content .= P::tag() @@ -201,6 +174,7 @@ private static function renderComponent(ViteComponent $component): string private static function renderHeader(ViteSummary $summary): string { $count = $summary->count(); + $items = [ Span::tag() ->html( @@ -238,7 +212,9 @@ private static function renderOverviewRow(string $term, Stringable|string $value return Tr::tag() ->html( - Th::tag()->scope('row')->content($term), + Th::tag() + ->scope('row') + ->content($term), $description, ); } diff --git a/src/PhpInfo/PhpInfoRenderer.php b/src/PhpInfo/PhpInfoRenderer.php index c10509d..2a25701 100644 --- a/src/PhpInfo/PhpInfoRenderer.php +++ b/src/PhpInfo/PhpInfoRenderer.php @@ -4,7 +4,7 @@ namespace PHPForge\Debug\PhpInfo; -use PHPForge\Debug\Helper\{Disclosure, Icon}; +use PHPForge\Debug\Helper\{Disclosure, ExtensionPill, Icon}; use UIAwesome\Html\Flow\{Div, Pre}; use UIAwesome\Html\Form\{Button, InputSearch}; use UIAwesome\Html\Interactive\{Details, Summary}; @@ -85,26 +85,11 @@ private static function renderCompactModule(PhpInfoCompactModule $module): Span $state = $enabled ? 'on' : 'off'; } - return Span::tag() + return ExtensionPill::render($module->title, $state, $enabled, $summary) ->addDataAttribute('section', $module->title) ->addDataAttribute('yii-debug-phpinfo-compact-module', true) - ->class('yii-debug-ext-pill ' . ($enabled ? 'is-on' : 'is-off')) ->id($module->slug) - ->title($summary) - ->html( - Span::tag() - ->addAriaAttribute('hidden', 'true') - ->class('yii-debug-ext-pill-dot'), - Span::tag() - ->class('yii-debug-ext-pill-label') - ->content($module->title), - Span::tag() - ->class('yii-debug-ext-pill-state') - ->content($state), - Span::tag() - ->class('yii-debug-sr-only') - ->content($summary), - ); + ->title($summary); } /** diff --git a/src/Routing/DebugUrlGeneratorInterface.php b/src/Routing/DebugUrlGeneratorInterface.php index cc8e0ae..39cbfeb 100644 --- a/src/Routing/DebugUrlGeneratorInterface.php +++ b/src/Routing/DebugUrlGeneratorInterface.php @@ -5,26 +5,10 @@ namespace PHPForge\Debug\Routing; /** - * Builds adapter-owned debugger URLs for framework-neutral panel renderers. + * Builds the adapter-owned captured-request panel URL consumed by framework-neutral panel renderers. */ interface DebugUrlGeneratorInterface { - /** - * Builds a debugger action URL scoped to a captured request. - * - * @param string $action Adapter-defined action identifier. - * @param string $tag Captured request tag. - * @param array $queryParams Additional query parameters. - */ - public function action(string $action, string $tag, array $queryParams = []): string; - - /** - * Builds the request-history URL. - * - * @param array $queryParams History filter, sort, or cursor parameters. - */ - public function history(array $queryParams = []): string; - /** * Builds a captured-request panel URL. * diff --git a/src/Storage/DebugArray.php b/src/Storage/DebugArray.php index 508e8e7..3eae368 100644 --- a/src/Storage/DebugArray.php +++ b/src/Storage/DebugArray.php @@ -70,12 +70,6 @@ public function jsonSerialize(): array /** * Returns captured entries as display-safe PHP values. * - * Usage example: - * - * ```php - * $values = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->values(); - * ``` - * * @return array Display-safe PHP values. */ public function values(): array diff --git a/src/Storage/DebugValue.php b/src/Storage/DebugValue.php index bdf6acd..8ac5bde 100644 --- a/src/Storage/DebugValue.php +++ b/src/Storage/DebugValue.php @@ -11,10 +11,6 @@ use Stringable; use Throwable; -use function array_diff_key; -use function array_is_list; -use function array_key_exists; -use function array_key_first; use function base64_decode; use function base64_encode; use function get_object_vars; @@ -42,33 +38,42 @@ final readonly class DebugValue implements JsonSerializable { /** - * @var array + * Defines the required fields for each encoded entry. + * + * @var list */ private const array ENTRY_SHAPE = [ - 'keyType' => true, - 'key' => true, - 'value' => true, + 'keyType', + 'key', + 'value', ]; + /** + * Limits the maximum nesting depth captured or hydrated. + */ private const int MAX_DEPTH = 10; + /** + * Limits the maximum number of tagged values processed during capture. + */ private const int MAX_NODES = 10000; - /** - * @var array> + * Maps each tagged value type to its required fields. + * + * @var array> */ private const array SHAPES = [ - 'null' => ['type' => true], - 'bool' => ['type' => true, 'value' => true], - 'int' => ['type' => true, 'value' => true], - 'float' => ['type' => true, 'value' => true], - 'special-float' => ['type' => true, 'value' => true], - 'string' => ['type' => true, 'value' => true], - 'binary' => ['type' => true, 'encoding' => true, 'data' => true], - 'array' => ['type' => true, 'entries' => true], - 'object' => ['type' => true, 'value' => true, 'entries' => true, 'class' => true], - 'resource' => ['type' => true, 'resourceType' => true], - 'truncated' => ['type' => true, 'value' => true, 'reason' => true], - 'recursion' => ['type' => true, 'value' => true, 'reason' => true], - 'unsupported' => ['type' => true, 'value' => true, 'reason' => true], + 'null' => ['type'], + 'bool' => ['type', 'value'], + 'int' => ['type', 'value'], + 'float' => ['type', 'value'], + 'special-float' => ['type', 'value'], + 'string' => ['type', 'value'], + 'binary' => ['type', 'encoding', 'data'], + 'array' => ['type', 'entries'], + 'object' => ['type', 'value', 'entries', 'class'], + 'resource' => ['type', 'resourceType'], + 'truncated' => ['type', 'value', 'reason'], + 'recursion' => ['type', 'value', 'reason'], + 'unsupported' => ['type', 'value', 'reason'], ]; /** @@ -176,12 +181,6 @@ public function jsonSerialize(): array /** * Returns the entries as a plain PHP array, keeping the key types captured from the original value. * - * Usage example: - * - * ```php - * $entries = \PHPForge\Debug\Storage\DebugValue::capture(['enabled' => true])->toDisplayEntries(); - * ``` - * * @return array Display-safe child values retaining captured key types. */ public function toDisplayEntries(): array @@ -198,12 +197,6 @@ public function toDisplayEntries(): array /** * Returns a safe PHP representation suitable for existing dump and table renderers. * - * Usage example: - * - * ```php - * $displayValue = \PHPForge\Debug\Storage\DebugValue::capture(new \stdClass())->toDisplayValue(); - * ``` - * * @return mixed Display-safe PHP value. */ public function toDisplayValue(): mixed @@ -218,22 +211,6 @@ public function toDisplayValue(): mixed }; } - /** - * Returns a required boolean without coercion. - * - * @param array $payload Validated payload. - */ - private static function bool(array $payload, string $key, string $path): bool - { - $value = self::required($payload, $key, $path); - - if (!is_bool($value)) { - throw HydrationException::at("{$path}.{$key}", 'a boolean'); - } - - return $value; - } - /** * Returns the display label for a non-scalar tagged value. * @@ -260,35 +237,33 @@ private function displayLabel(): string */ private static function entryObject(mixed $value, string $path): array { - $entry = self::object($value, $path); - - self::validateShape($entry, self::ENTRY_SHAPE, $path); + $entry = Payload::object($value, $path)->shape(self::ENTRY_SHAPE); return [ - 'keyType' => self::required($entry, 'keyType', $path), - 'key' => self::required($entry, 'key', $path), - 'value' => self::required($entry, 'value', $path), + 'keyType' => $entry->raw('keyType'), + 'key' => $entry->raw('key'), + 'value' => $entry->raw('value'), ]; } /** * Hydrates a base64-encoded binary value. * - * @param array $payload Validated tagged value payload. + * @param Payload $payload Validated tagged value payload. * @param string $path Payload path used in hydration errors. * * @return self Hydrated binary value. */ - private static function fromBinary(array $payload, string $path): self + private static function fromBinary(Payload $payload, string $path): self { - if (self::string($payload, 'encoding', $path) !== 'base64') { + if ($payload->string('encoding') !== 'base64') { throw HydrationException::at( "{$path}.encoding", 'base64', ); } - $decoded = base64_decode(self::string($payload, 'data', $path), true); + $decoded = base64_decode($payload->string('data'), true); if ($decoded === false) { throw HydrationException::at( @@ -306,14 +281,14 @@ private static function fromBinary(array $payload, string $path): self /** * Hydrates a non-finite floating-point label. * - * @param array $payload Validated tagged value payload. + * @param Payload $payload Validated tagged value payload. * @param string $path Payload path used in hydration errors. * * @return self Hydrated non-finite floating-point value. */ - private static function fromSpecialFloat(array $payload, string $path): self + private static function fromSpecialFloat(Payload $payload, string $path): self { - $value = self::string($payload, 'value', $path); + $value = $payload->string('value'); if (!in_array($value, ['NAN', 'INF', '-INF'], true)) { throw HydrationException::at( @@ -352,27 +327,27 @@ private static function hydrate(mixed $data, string $path, int $depth, int &$nod return match ($type) { 'null' => new self('null'), - 'bool' => new self('bool', self::bool($payload, 'value', $path)), - 'int' => new self('int', self::int($payload, 'value', $path)), - 'float' => new self('float', self::number($payload, 'value', $path)), + 'bool' => new self('bool', $payload->bool('value')), + 'int' => new self('int', $payload->int('value')), + 'float' => new self('float', $payload->number('value')), 'special-float' => self::fromSpecialFloat($payload, $path), - 'string' => new self('string', self::string($payload, 'value', $path)), + 'string' => new self('string', $payload->string('value')), 'binary' => self::fromBinary($payload, $path), 'array' => new self('array', entries: self::hydrateEntries($payload, $path, $depth, $nodes)), 'object' => new self( 'object', - value: self::nullableString($payload, 'value', $path), + value: $payload->nullableString('value'), entries: self::hydrateEntries($payload, $path, $depth, $nodes), - className: self::string($payload, 'class', $path), + className: $payload->string('class'), ), 'resource' => new self( 'resource', - resourceType: self::string($payload, 'resourceType', $path), + resourceType: $payload->string('resourceType'), ), 'truncated', 'recursion', 'unsupported' => new self( $type, - value: self::nullableString($payload, 'value', $path), - reason: self::string($payload, 'reason', $path), + value: $payload->nullableString('value'), + reason: $payload->string('reason'), ), }; } @@ -380,16 +355,16 @@ className: self::string($payload, 'class', $path), /** * Hydrates tagged array or object entries. * - * @param array $payload Validated tagged value payload. + * @param Payload $payload Validated tagged value payload. * @param string $path Payload path used in hydration errors. * * @return list Hydrated entries. */ - private static function hydrateEntries(array $payload, string $path, int $depth, int &$nodes): array + private static function hydrateEntries(Payload $payload, string $path, int $depth, int &$nodes): array { $entries = []; - foreach (self::list($payload, 'entries', $path) as $index => $rawEntry) { + foreach ($payload->list('entries') as $index => $rawEntry) { $entryPath = "{$path}.entries[{$index}]"; $entry = self::entryObject($rawEntry, $entryPath); @@ -427,40 +402,6 @@ private static function hydrateEntries(array $payload, string $path, int $depth, return $entries; } - /** - * Returns a required integer without coercion. - * - * @param array $payload Validated payload. - */ - private static function int(array $payload, string $key, string $path): int - { - $value = self::required($payload, $key, $path); - - if (!is_int($value)) { - throw HydrationException::at("{$path}.{$key}", 'an integer'); - } - - return $value; - } - - /** - * Returns a required sequential list. - * - * @param array $payload Validated payload. - * - * @return list Validated list. - */ - private static function list(array $payload, string $key, string $path): array - { - $value = self::required($payload, $key, $path); - - if (!is_array($value) || !array_is_list($value)) { - throw HydrationException::at("{$path}.{$key}", 'a list'); - } - - return $value; - } - /** * Normalizes a PHP value while enforcing depth and node limits. * @@ -612,58 +553,6 @@ className: $value::class, ); } - /** - * Returns a string or `null` without coercion. - * - * @param array $payload Validated payload. - */ - private static function nullableString(array $payload, string $key, string $path): string|null - { - $value = self::required($payload, $key, $path); - - if ($value !== null && !is_string($value)) { - throw HydrationException::at("{$path}.{$key}", 'a string or null'); - } - - return $value; - } - - /** - * Returns a required finite number as a float. - * - * @param array $payload Validated payload. - */ - private static function number(array $payload, string $key, string $path): float - { - $value = self::required($payload, $key, $path); - - if (!is_int($value) && (!is_float($value) || !is_finite($value))) { - throw HydrationException::at("{$path}.{$key}", 'a number'); - } - - return (float) $value; - } - - /** - * Returns a decoded JSON object with string keys. - * - * @return array Validated object fields. - */ - private static function object(mixed $value, string $path): array - { - if (!is_array($value) || (array_is_list($value) && $value !== [])) { - throw HydrationException::at($path, 'an object'); - } - - foreach ($value as $key => $_) { - if (!is_string($key)) { - throw HydrationException::at($path, 'an object with string keys'); - } - } - - return $value; - } - /** * Returns a safe display label for an object. * @@ -696,47 +585,17 @@ private static function objectLabel(object $value): string return Json::safeString($value::class); } - /** - * Returns a required field without coercion. - * - * @param array $payload Validated payload. - */ - private static function required(array $payload, string $key, string $path): mixed - { - if (!array_key_exists($key, $payload)) { - throw HydrationException::at("{$path}.{$key}", 'a required field'); - } - - return $payload[$key]; - } - - /** - * Returns a required string without coercion. - * - * @param array $payload Validated payload. - */ - private static function string(array $payload, string $key, string $path): string - { - $value = self::required($payload, $key, $path); - - if (!is_string($value)) { - throw HydrationException::at("{$path}.{$key}", 'a string'); - } - - return $value; - } - /** * Returns a decoded tagged-value object matching its type-specific shape. * * @param-out DebugValueType $type Validated tagged-value type. * - * @return array Validated tagged-value fields. + * @return Payload Validated tagged-value reader. */ - private static function taggedObject(mixed $value, string $path, string &$type): array + private static function taggedObject(mixed $value, string $path, string &$type): Payload { - $payload = self::object($value, $path); - $rawType = self::required($payload, 'type', $path); + $payload = Payload::object($value, $path); + $rawType = $payload->raw('type'); if (!is_string($rawType)) { throw HydrationException::at("{$path}.type", 'a string'); @@ -759,33 +618,6 @@ private static function taggedObject(mixed $value, string $path, string &$type): default => throw HydrationException::at("{$path}.type", 'a known debug-value type'), }; - $shape = self::SHAPES[$type]; - - self::validateShape($payload, $shape, $path); - - return $payload; - } - - /** - * Validates required and undeclared fields against a cached shape map. - * - * @param array $payload Decoded payload. - * @param array $shape Required fields indexed by name. - */ - private static function validateShape(array $payload, array $shape, string $path): void - { - foreach ($shape as $key => $_) { - if (!array_key_exists($key, $payload)) { - throw HydrationException::at("{$path}.{$key}", 'a required field'); - } - } - - $unknown = array_diff_key($payload, $shape); - - if ($unknown !== []) { - $key = array_key_first($unknown); - - throw HydrationException::at("{$path}.{$key}", 'a declared field'); - } + return $payload->shape(self::SHAPES[$type]); } } diff --git a/src/Storage/ExceptionSnapshot.php b/src/Storage/ExceptionSnapshot.php index af172b3..e4d0e60 100644 --- a/src/Storage/ExceptionSnapshot.php +++ b/src/Storage/ExceptionSnapshot.php @@ -6,6 +6,7 @@ use JsonSerializable; use PHPForge\Debug\Capture\CapturePolicy; +use PHPForge\Debug\Helper\{Coerce, Fqcn}; use SensitiveParameter; use Stringable; use Throwable; @@ -13,8 +14,6 @@ use function array_map; use function is_int; use function is_string; -use function strrpos; -use function substr; /** * Represents a captured throwable without executable state. @@ -155,13 +154,13 @@ public static function fromThrowable(#[SensitiveParameter] Throwable $throwable) $trace = []; foreach ($throwable->getTrace() as $entry) { - $class = is_string($entry['class'] ?? null) ? $entry['class'] : ''; + $class = Coerce::string($entry['class'] ?? null); $trace[] = [ - 'namespace' => Json::safeString(self::namespacePart($class)), - 'short_class' => Json::safeString(self::shortName($class)), + 'namespace' => Json::safeString(Fqcn::namespacePart($class)), + 'short_class' => Json::safeString(Fqcn::shortName($class)), 'class' => Json::safeString($class), - 'type' => Json::safeString(is_string($entry['type'] ?? null) ? $entry['type'] : ''), + 'type' => Json::safeString(Coerce::string($entry['type'] ?? null)), 'function' => Json::safeString($entry['function']), 'file' => is_string($entry['file'] ?? null) ? Json::safeString($entry['file']) : null, 'line' => is_int($entry['line'] ?? null) ? $entry['line'] : null, @@ -283,32 +282,4 @@ public function jsonSerialize(): array 'previous' => $this->previous?->jsonSerialize(), ]; } - - /** - * Returns the namespace portion of a fully qualified class name. - * - * @param string $class Fully qualified class name. - * - * @return string Namespace portion or an empty string. - */ - private static function namespacePart(string $class): string - { - $position = strrpos($class, '\\'); - - return $position === false ? '' : substr($class, 0, $position); - } - - /** - * Returns the short portion of a fully qualified class name. - * - * @param string $class Fully qualified class name. - * - * @return string Short class name. - */ - private static function shortName(string $class): string - { - $position = strrpos($class, '\\'); - - return $position === false ? $class : substr($class, $position + 1); - } } diff --git a/src/Storage/Payload.php b/src/Storage/Payload.php index 16b516b..8be88d9 100644 --- a/src/Storage/Payload.php +++ b/src/Storage/Payload.php @@ -128,6 +128,55 @@ public function map(string $key): array return self::object($this->value($key), $this->keyPath($key))->data; } + /** + * Reads a list field and maps every element through a factory, passing the indexed element path for error + * reporting. + * + * @template T + * + * @param string $key Required field name. + * @param callable(mixed, string): T $factory Element factory receiving the raw element and its indexed path. + * + * @return list Mapped elements in payload order. + */ + public function mapList(string $key, callable $factory): array + { + $path = $this->keyPath($key); + + $items = []; + + foreach ($this->list($key) as $index => $item) { + $items[] = $factory($item, "{$path}[{$index}]"); + } + + return $items; + } + + /** + * Returns a boolean field or `null`. + * + * @param string $key Required field name. + * + * @return bool|null Boolean field value or `null`. + */ + public function nullableBool(string $key): bool|null + { + $value = $this->value($key); + + if ($value === null) { + return null; + } + + if (!is_bool($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a boolean or null', + ); + } + + return $value; + } + /** * Returns an integer field or `null`. * diff --git a/src/Storage/RequestSummary.php b/src/Storage/RequestSummary.php index f114dcb..6cb568b 100644 --- a/src/Storage/RequestSummary.php +++ b/src/Storage/RequestSummary.php @@ -30,7 +30,7 @@ * @param float|null $processingTime Processing duration in seconds or `null` when unavailable. * @param int|null $peakMemory Peak memory in bytes or `null` when unavailable. */ - public function __construct( + private function __construct( public string $tag, public string $url, public bool $ajax, diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php index 0e93cce..7339e3e 100644 --- a/src/Storage/SnapshotStore.php +++ b/src/Storage/SnapshotStore.php @@ -30,13 +30,28 @@ */ final class SnapshotStore { + /** + * Names the manifest index file. + */ private const string INDEX_FILE = 'index.json'; + /** + * Defines flags used for deterministic JSON encoding. + */ private const int JSON_FLAGS = JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION; + /** + * Names the manifest lock file. + */ private const string LOCK_FILE = 'index.lock'; + /** + * Names the transaction journal file. + */ private const string TRANSACTION_FILE = '.debug-transaction.json'; + /** + * Identifies the transaction journal format version. + */ private const int TRANSACTION_VERSION = 1; /** @@ -101,13 +116,7 @@ public function loadManifest(): array public function loadManifestResult(): ManifestReadResult { if (!is_dir($this->path)) { - $error = is_file($this->path) - ? new StorageException( - Message::DATA_PATH_NOT_DIRECTORY->getMessage($this->path), - ) - : null; - - return new ManifestReadResult([], $error); + return new ManifestReadResult([], $this->directoryMissingError()); } try { @@ -125,21 +134,9 @@ public function loadManifestResult(): ManifestReadResult return new ManifestReadResult([], null); } - $raw = @file_get_contents($file); - - if ($raw === false) { - throw new StorageException( - Message::MANIFEST_READ_FAILED->getMessage($file), - ); - } - - if ($raw === '') { - throw new StorageException( - Message::MANIFEST_EMPTY->getMessage($file), - ); - } - - $manifest = Manifest::fromArray(self::decode($raw)); + $manifest = Manifest::fromArray( + self::decode(self::readRequiredFile($file, Message::MANIFEST_READ_FAILED, Message::MANIFEST_EMPTY)), + ); return new ManifestReadResult(array_reverse($manifest->entries, true), null); } catch (Throwable $failure) { @@ -188,13 +185,7 @@ public function readSnapshotResult(string $tag): SnapshotReadResult } if (!is_dir($this->path)) { - $error = is_file($this->path) - ? new StorageException( - Message::DATA_PATH_NOT_DIRECTORY->getMessage($this->path), - ) - : null; - - return new SnapshotReadResult(null, $error); + return new SnapshotReadResult(null, $this->directoryMissingError()); } try { @@ -212,21 +203,9 @@ public function readSnapshotResult(string $tag): SnapshotReadResult return new SnapshotReadResult(null, null); } - $raw = @file_get_contents($file); - - if ($raw === false) { - throw new StorageException( - Message::SNAPSHOT_READ_FAILED->getMessage($file), - ); - } - - if ($raw === '') { - throw new StorageException( - Message::SNAPSHOT_EMPTY->getMessage($file), - ); - } - - $snapshot = DebugSnapshot::fromArray(self::decode($raw)); + $snapshot = DebugSnapshot::fromArray( + self::decode(self::readRequiredFile($file, Message::SNAPSHOT_READ_FAILED, Message::SNAPSHOT_EMPTY)), + ); if ($snapshot->summary->tag !== $tag) { throw new StorageException( @@ -475,6 +454,20 @@ private static function decode(string $json): mixed return json_decode($json, true, flags: JSON_THROW_ON_ERROR); } + /** + * Returns the diagnostic for a storage path that exists but is not a directory, or `null` when it is simply absent. + * + * @return StorageException|null Path diagnostic or `null`. + */ + private function directoryMissingError(): StorageException|null + { + return is_file($this->path) + ? new StorageException( + Message::DATA_PATH_NOT_DIRECTORY->getMessage($this->path), + ) + : null; + } + /** * Encodes a value as deterministic JSON. * @@ -598,12 +591,41 @@ private function readManifestFile(): array } } + /** + * Reads a stored document that must exist and carry content. + * + * @param string $file Document path. + * @param Message $failed Diagnostic used when the read fails. + * @param Message $empty Diagnostic used when the document is empty. + * + * @return string Raw document contents. + */ + private static function readRequiredFile(string $file, Message $failed, Message $empty): string + { + $raw = @file_get_contents($file); + + if ($raw === false) { + throw new StorageException( + $failed->getMessage($file), + ); + } + + if ($raw === '') { + throw new StorageException( + $empty->getMessage($file), + ); + } + + return $raw; + } + /** * Rebuilds a corrupt manifest from valid snapshot envelopes in deterministic request-time order. */ private function rebuildManifest(): Manifest { $summaries = []; + $files = glob("{$this->path}/*.json"); foreach ($files === false ? [] : $files as $file) { @@ -655,6 +677,7 @@ private function rebuildManifest(): Manifest private function recoverTransaction(): void { $file = $this->transactionFile(); + $raw = @file_get_contents($file); if ($raw === false || $raw === '') { diff --git a/src/Toolbar/ToolbarData.php b/src/Toolbar/ToolbarData.php index bfb1359..5ced4ef 100644 --- a/src/Toolbar/ToolbarData.php +++ b/src/Toolbar/ToolbarData.php @@ -61,12 +61,6 @@ public static function create(string $tag, string $title): self /** * Returns the complete payload consumed by the toolbar runtime. * - * Usage example: - * - * ```php - * $payload = $toolbarData->jsonSerialize(); - * ``` - * * @return array{ * configUrl: string, * defaultHeight: int, diff --git a/src/Toolbar/ToolbarInjector.php b/src/Toolbar/ToolbarInjector.php new file mode 100644 index 0000000..14f92fa --- /dev/null +++ b/src/Toolbar/ToolbarInjector.php @@ -0,0 +1,31 @@ +'); + + return $offset === false + ? "{$html}{$toolbar}" + : substr_replace($html, $toolbar, $offset, 0); + } +} diff --git a/src/Toolbar/ToolbarItem.php b/src/Toolbar/ToolbarItem.php index 71b2a30..55173fb 100644 --- a/src/Toolbar/ToolbarItem.php +++ b/src/Toolbar/ToolbarItem.php @@ -22,7 +22,7 @@ * @param string|null $url Debug page URL or `null` when the metric is not navigable. * @param string|null $id Stable semantic metric identifier or `null` for presentation-only metrics. */ - public function __construct( + private function __construct( public string $value, public string|null $label = null, public string|null $icon = null, diff --git a/src/Toolbar/ToolbarPanel.php b/src/Toolbar/ToolbarPanel.php index 3968d76..e0cc538 100644 --- a/src/Toolbar/ToolbarPanel.php +++ b/src/Toolbar/ToolbarPanel.php @@ -21,7 +21,7 @@ * @param string|null $icon Shared icon name or `null` when no icon is available. * @param list $items Panel metrics. */ - public function __construct( + private function __construct( public string $id, public string $title, public string|null $url = null, diff --git a/src/View/History/HistoryCellRenderer.php b/src/View/History/HistoryCellRenderer.php index d165712..6c40d31 100644 --- a/src/View/History/HistoryCellRenderer.php +++ b/src/View/History/HistoryCellRenderer.php @@ -12,7 +12,6 @@ use function date; use function implode; -use function number_format; /** * Renders the History index summary header + the per-cell HTML consumed by the grid columns and the typed @@ -69,7 +68,7 @@ public static function renderDurationCell(HistoryRow $row, float $maxProcessingT } return Gauge::render( - number_format($row->processingTime * 1000) . ' ms', + Format::milliseconds($row->processingTime), $row->processingTime, $maxProcessingTime, ); diff --git a/src/View/Sidebar/SidebarSnapshot.php b/src/View/Sidebar/SidebarSnapshot.php index 8efb734..87affc5 100644 --- a/src/View/Sidebar/SidebarSnapshot.php +++ b/src/View/Sidebar/SidebarSnapshot.php @@ -10,7 +10,7 @@ */ final readonly class SidebarSnapshot { - public function __construct( + private function __construct( /** * Section heading shown above the snapshot card ('Current request' / 'Newest request'). */ diff --git a/tests/Collector/CollectorCoordinatorTest.php b/tests/Collector/CollectorCoordinatorTest.php index 952aac5..dc4496e 100644 --- a/tests/Collector/CollectorCoordinatorTest.php +++ b/tests/Collector/CollectorCoordinatorTest.php @@ -15,8 +15,6 @@ /** * Unit tests for {@see CollectorCoordinator} validating IDs, lifecycle, and isolated capture failures. - * - * @since 0.1 */ #[Group('collector')] final class CollectorCoordinatorTest extends TestCase @@ -193,7 +191,9 @@ public function testRunIgnoresFailingCleanupDiagnosticAndPreservesPrimaryThrowab try { $coordinator->run( static fn(): never => throw $primary, - static fn(Throwable $_throwable): never => throw new RuntimeException('Diagnostic failed.'), + static fn(Throwable $_throwable): never => throw new RuntimeException( + 'Diagnostic failed.', + ), ); } catch (RuntimeException $throwable) { $caught = $throwable; @@ -702,20 +702,8 @@ public function testThrowRuntimeExceptionWhenRollbackShutdownFails(): void */ private function summary(): RequestSummary { - return new RequestSummary( - tag: 'request-1', - url: 'https://example.test/', - ajax: false, - method: 'GET', - ip: '127.0.0.1', - time: 1_700_000_000.0, - statusCode: 200, - sqlCount: 0, - excessiveCallersCount: 0, - mailCount: 0, - mailFiles: [], - processingTime: null, - peakMemory: null, - ); + return RequestSummary::create('request-1') + ->withRequest('https://example.test/', 'GET', '127.0.0.1', 1_700_000_000.0) + ->withResponse(200); } } diff --git a/tests/Comparison/PanelComparisonTest.php b/tests/Comparison/PanelComparisonTest.php index 348bda8..d3e8e63 100644 --- a/tests/Comparison/PanelComparisonTest.php +++ b/tests/Comparison/PanelComparisonTest.php @@ -10,6 +10,8 @@ use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; use PHPUnit\Framework\TestCase; +use function array_map; + /** * Locks the original adapter output before and after sharing panel comparison. */ @@ -34,10 +36,12 @@ public function testBetweenPreservesPanelContracts( $panels = PanelComparison::between($baseline, $target, $labels); $actual = []; + $actualCounts = []; $differenceCount = 0; foreach ($panels as $panel) { - $differenceCount += $panel->added + $panel->removed + $panel->changed; + $differenceCount += $panel->differenceCount(); + $actualCounts[] = $panel->differenceCount(); $actual[] = [ $panel->id, $panel->label, @@ -55,6 +59,11 @@ public function testBetweenPreservesPanelContracts( $actual, 'Panel IDs, labels, order, states, and counts must remain exact.', ); + self::assertSame( + array_map(static fn(array $row): int => $row[4] + $row[5] + $row[6], $expected), + $actualCounts, + 'Totals must add added, removed, and changed leaves.', + ); self::assertSame( $hasDifferences, $differenceCount > 0, diff --git a/tests/Comparison/PayloadDifferenceTest.php b/tests/Comparison/PayloadDifferenceTest.php index 9656b0d..6460b0b 100644 --- a/tests/Comparison/PayloadDifferenceTest.php +++ b/tests/Comparison/PayloadDifferenceTest.php @@ -5,145 +5,26 @@ namespace PHPForge\Debug\Tests\Comparison; use PHPForge\Debug\Comparison\PayloadDifference; -use PHPUnit\Framework\Attributes\{DataProvider, Group}; +use PHPForge\Debug\Tests\Provider\PayloadDifferenceProvider; +use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; use PHPUnit\Framework\TestCase; use function get_object_vars; /** * Unit tests for typed structural comparisons without retaining or modifying diagnostic values. + * + * {@see PayloadDifferenceProvider} for test case data providers. */ #[Group('history')] final class PayloadDifferenceTest extends TestCase { - /** - * @return iterable|null, array|null, array{int, int, int, int}}> - */ - public static function payloads(): iterable - { - yield 'binary strings' => [ - ['value' => "\xFF\0"], - ['value' => "\xFE\0"], - [0, 0, 1, 0], - ]; - yield 'both absent' => [ - null, - null, - [0, 0, 0, 0], - ]; - yield 'both empty' => [ - [], - [], - [0, 0, 0, 1], - ]; - yield 'empty array versus null' => [ - ['value' => []], - ['value' => null], - [0, 0, 1, 0], - ]; - yield 'empty array versus string' => [ - ['value' => []], - ['value' => 'array:[]'], - [0, 0, 1, 0], - ]; - yield 'empty captured' => [ - null, - [], - [1, 0, 0, 0], - ]; - yield 'empty key versus root' => [ - ['' => []], - [], - [1, 1, 0, 0], - ]; - yield 'empty removed' => [ - [], - null, - [0, 1, 0, 0], - ]; - yield 'false versus zero' => [ - ['value' => false], - ['value' => 0], - [0, 0, 1, 0], - ]; - yield 'integer versus float' => [ - ['value' => 0], - ['value' => 0.0], - [0, 0, 1, 0], - ]; - yield 'list order' => [ - ['items' => [1, 2]], - ['items' => [2, 1]], - [0, 0, 2, 0], - ]; - yield 'map order' => [ - ['a' => 1, 'b' => 2], - ['b' => 2, 'a' => 1], - [0, 0, 0, 2], - ]; - yield 'mixed counters' => [ - ['removed' => 'raw', 'items' => [1, 2], 'value' => false], - ['added' => 'raw', 'items' => [1, 2, 3], 'value' => null], - [2, 1, 1, 2], - ]; - yield 'nested empty expanded' => [ - ['value' => []], - ['value' => [false]], - [1, 1, 0, 0], - ]; - yield 'null leaf captured' => [ - null, - ['value' => null], - [1, 0, 0, 0], - ]; - yield 'null leaf unchanged' => [ - ['value' => null], - ['value' => null], - [0, 0, 0, 1], - ]; - yield 'null versus false' => [ - ['value' => null], - ['value' => false], - [0, 0, 1, 0], - ]; - yield 'slash versus escaped tilde' => [ - ['a/b' => 1], - ['a~0b' => 1], - [1, 1, 0, 0], - ]; - yield 'slash versus nesting' => [ - ['a/b' => 1], - ['a' => ['b' => 1]], - [1, 1, 0, 0], - ]; - yield 'slash versus plain key' => [ - ['a/b' => 1], - ['ab' => 1], - [1, 1, 0, 0], - ]; - yield 'tilde escape collision' => [ - ['a~0b' => 1], - ['a~b' => 1], - [1, 1, 0, 0], - ]; - yield 'tilde versus escaped slash' => [ - ['a~1b' => 1], - ['a/b' => 1], - [1, 1, 0, 0], - ]; - yield 'zero versus string' => [ - ['value' => 0], - ['value' => '0'], - [0, 0, 1, 0], - ]; - } - /** * @param array|null $baseline * @param array|null $target * @param array{int, int, int, int} $expected */ - #[DataProvider('payloads')] + #[DataProviderExternal(PayloadDifferenceProvider::class, 'payloads')] public function testBetweenPreservesTypedLeafSemantics(array|null $baseline, array|null $target, array $expected): void { $originalBaseline = $baseline; diff --git a/tests/Comparison/SnapshotComparisonTest.php b/tests/Comparison/SnapshotComparisonTest.php new file mode 100644 index 0000000..ccfd324 --- /dev/null +++ b/tests/Comparison/SnapshotComparisonTest.php @@ -0,0 +1,133 @@ + ['queries' => 1]]); + $target = self::snapshot(['db' => ['queries' => 2]]); + + $comparison = SnapshotComparison::between($baseline, $target, ['db' => 'Database']); + + self::assertSame( + $baseline, + $comparison->baseline, + 'The baseline snapshot must be retained as given.', + ); + self::assertSame( + $target, + $comparison->target, + 'The target snapshot must be retained as given.', + ); + self::assertSame( + [ + 'Status', + 'Method', + 'AJAX', + 'Duration', + 'Peak memory', + 'SQL queries', + 'Mail messages', + 'Excessive DB callers', + ], + array_map(static fn(SummaryMetricComparison $metric): string => $metric->label, $comparison->metrics), + 'Metrics must keep the canonical history order.', + ); + self::assertSame( + [['db', 'Database', 'Captured', 'Captured', 0, 0, 1, 0]], + array_map( + static fn(PanelComparison $panel): array => [ + $panel->id, + $panel->label, + $panel->baselineState, + $panel->targetState, + $panel->added, + $panel->removed, + $panel->changed, + $panel->unchanged, + ], + $comparison->panels, + ), + 'Panels must carry the configured label, states, and counts.', + ); + } + + public function testHasDifferencesReportsChangedPanelPayloads(): void + { + $comparison = SnapshotComparison::between( + self::snapshot(['db' => ['queries' => 1]]), + self::snapshot(['db' => ['queries' => 2]]), + ); + + self::assertSame( + [false, false, false, false, false, false, false, false], + array_map( + static fn(SummaryMetricComparison $metric): bool => $metric->hasDifference(), + $comparison->metrics, + ), + 'Identical summaries must leave every metric unchanged.', + ); + self::assertTrue( + $comparison->hasDifferences(), + 'A changed panel leaf must be reported.', + ); + } + + public function testHasDifferencesReportsChangedSummaryMetrics(): void + { + $panels = ['db' => ['queries' => 1]]; + + $comparison = SnapshotComparison::between( + new DebugSnapshot(RequestSummary::create('capture')->withResponse(200), $panels, []), + new DebugSnapshot(RequestSummary::create('capture')->withResponse(500), $panels, []), + ); + + self::assertSame( + [0], + array_map( + static fn(PanelComparison $panel): int => $panel->differenceCount(), + $comparison->panels, + ), + 'Identical payloads must leave the panel without differences.', + ); + self::assertTrue( + $comparison->hasDifferences(), + 'A changed status metric must be reported.', + ); + } + + public function testHasDifferencesReturnsFalseForIdenticalCaptures(): void + { + $panels = ['db' => ['queries' => 1]]; + + self::assertFalse( + SnapshotComparison::between(self::snapshot($panels), self::snapshot($panels))->hasDifferences(), + 'Equal summaries and payloads must report no difference.', + ); + } + + /** + * Returns a capture carrying the given panel payloads and an otherwise empty summary. + * + * @param array> $panels Serialized panel payloads indexed by panel ID. + */ + private static function snapshot(array $panels): DebugSnapshot + { + return new DebugSnapshot(RequestSummary::create('capture'), $panels, []); + } +} diff --git a/tests/Comparison/SummaryMetricComparisonTest.php b/tests/Comparison/SummaryMetricComparisonTest.php index 0d65540..0130377 100644 --- a/tests/Comparison/SummaryMetricComparisonTest.php +++ b/tests/Comparison/SummaryMetricComparisonTest.php @@ -10,6 +10,8 @@ use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; use PHPUnit\Framework\TestCase; +use function array_map; + /** * Tests summary metric compatibility with the original adapter calculations and formatting. */ @@ -27,6 +29,7 @@ public function testBetweenPreservesAllMetricContracts( ): void { $beforeBaseline = $baseline->jsonSerialize(); $beforeTarget = $target->jsonSerialize(); + $actual = []; foreach (SummaryMetricComparison::between($baseline, $target) as $metric) { @@ -75,6 +78,32 @@ public function testBetweenPreservesMetricBoundaries( ); } + public function testHasDifferenceFollowsTheFormattedDelta(): void + { + $baseline = RequestSummary::create('baseline'); + + self::assertSame( + [false, false, false, false, false, false, false, false], + self::differences(SummaryMetricComparison::between($baseline, $baseline)), + 'Identical summaries must leave every metric unchanged.', + ); + self::assertSame( + [true, false, false, false, false, false, false, false], + self::differences(SummaryMetricComparison::between($baseline, $baseline->withResponse(500))), + 'Only the changed status metric must be reported.', + ); + } + + /** + * @param list $metrics + * + * @return list + */ + private static function differences(array $metrics): array + { + return array_map(static fn(SummaryMetricComparison $metric): bool => $metric->hasDifference(), $metrics); + } + /** * @return array{string, string, string, string, string, string|null} */ diff --git a/tests/Data/PageSizeTest.php b/tests/Data/PageSizeTest.php index 8d0d7b5..b942e72 100644 --- a/tests/Data/PageSizeTest.php +++ b/tests/Data/PageSizeTest.php @@ -99,21 +99,34 @@ public function testResolveReturnsNullForTheAllKeyword(): void ); } - public function testSelectorForReadsThePageSizeFromTheQuery(): void + public function testSelectorForFallsBackToTheDefaultWhenTheParameterIsMissing(): 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)), + <<Rows + HTML, PageSize::selectorFor([]), - 'A missing parameter must fall back to the default selector.', + 'A missing parameter must preselect the default page size.', ); } - public function testSelectorHtmlMarksTheCurrentOptionSelected(): void + public function testSelectorForMarksTheCurrentOptionSelected(): void { self::assertSame( << HTML, - PageSize::selectorHtml('25'), + PageSize::selectorFor(['per-page' => '25']), 'The selector must render the exact JS hook, field name, labels, and selected option.', ); } - public function testSelectorHtmlSelectsCanonicalizedAllKeyword(): void + public function testSelectorForSelectsCanonicalizedAllKeyword(): void { self::assertSame( << HTML, - PageSize::selectorHtml(PageSize::current('ALL')), + PageSize::selectorFor(['per-page' => 'ALL']), "The canonicalized 'all' keyword must render the exact selected selector.", ); } diff --git a/tests/Data/QueryInputTest.php b/tests/Data/QueryInputTest.php index f207a40..430baa0 100644 --- a/tests/Data/QueryInputTest.php +++ b/tests/Data/QueryInputTest.php @@ -9,8 +9,8 @@ use PHPUnit\Framework\TestCase; /** - * Unit tests for {@see QueryInput} covering `Prefix[attribute]` group extraction and scalar top-level reads from - * parsed query parameters. + * Unit tests for {@see QueryInput} covering `Prefix[attribute]` group extraction and scalar top-level reads from parsed + * query parameters. */ #[Group('data')] #[Group('filter')] @@ -55,6 +55,45 @@ public function testGroupReturnsEmptyArrayWhenPrefixIsAbsentOrNotAnArray(): void ); } + public function testMinimumBoundAcceptsFiniteNonNegativeNumbers(): void + { + self::assertSame( + 12.5, + QueryInput::minimumBound('12.5'), + 'A decimal bound must be returned as a float.', + ); + self::assertSame( + 0.0, + QueryInput::minimumBound('0'), + 'Zero must remain an accepted lower bound.', + ); + self::assertSame( + 1.0E+3, + QueryInput::minimumBound('1e3'), + 'Scientific notation must be accepted.', + ); + } + + public function testMinimumBoundRejectsUnusableValues(): void + { + self::assertNull( + QueryInput::minimumBound(''), + 'Empty input must be rejected.', + ); + self::assertNull( + QueryInput::minimumBound('12ms'), + 'Non-numeric input must be rejected.', + ); + self::assertNull( + QueryInput::minimumBound('-0.5'), + 'Negative bounds must be rejected.', + ); + self::assertNull( + QueryInput::minimumBound('1e400'), + 'Overflowing input must be rejected.', + ); + } + public function testScalarReadsTopLevelStringsAndNumbers(): void { self::assertSame( diff --git a/tests/Helper/AvatarTest.php b/tests/Helper/AvatarTest.php index 8178c5a..d3f873c 100644 --- a/tests/Helper/AvatarTest.php +++ b/tests/Helper/AvatarTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; /** - * Unit tests for {@see Avatar} deriving deterministic avatar hues. + * Unit tests for {@see Avatar} deriving deterministic avatar hues and monogram initials. */ #[Group('avatar')] #[Group('helpers')] @@ -37,4 +37,32 @@ public function testHueForReturnsFallbackForEmptySeed(): void 'Empty seeds must use the fallback hue.', ); } + + public function testInitialReturnsFallbackForEmptySeed(): void + { + self::assertSame( + '?', + Avatar::initial(''), + 'Empty seeds must fall back to a question mark.', + ); + } + + public function testInitialUppercasesTheFirstMultibyteCharacter(): void + { + self::assertSame( + 'A', + Avatar::initial('alice'), + 'Leading letter must be uppercased.', + ); + self::assertSame( + 'Á', + Avatar::initial('álvaro'), + 'Accented letter must survive as a single character.', + ); + self::assertSame( + '9', + Avatar::initial('9lives'), + 'Non-letter seeds must keep their first character.', + ); + } } diff --git a/tests/Helper/BadgeTest.php b/tests/Helper/BadgeTest.php new file mode 100644 index 0000000..57b1724 --- /dev/null +++ b/tests/Helper/BadgeTest.php @@ -0,0 +1,39 @@ +Matched + HTML, + Badge::render('Matched', 'success', 'yii-debug-route-match')->render(), + 'Modifier must follow the variant class.', + ); + } + + public function testRenderCarriesTheVariantClassWithoutAModifier(): void + { + self::assertSame( + <<Not matched + HTML, + Badge::render('Not matched', 'muted')->render(), + 'Chip must carry the base and variant classes only.', + ); + } +} diff --git a/tests/Helper/ExtensionPillTest.php b/tests/Helper/ExtensionPillTest.php new file mode 100644 index 0000000..5f23665 --- /dev/null +++ b/tests/Helper/ExtensionPillTest.php @@ -0,0 +1,39 @@ +Zend OPcache8.5Version: 8.5 + HTML, + ExtensionPill::render('Zend OPcache', '8.5', false, 'Version: 8.5')->render(), + 'Disabled pills must carry `is-off` and the extra detail span.', + ); + } + + public function testRenderOmitsTheScreenReaderSummaryForEnabledExtensions(): void + { + self::assertSame( + <<curlon + HTML, + ExtensionPill::render('curl', 'on', true)->render(), + 'Enabled pills must carry `is-on` and only three spans.', + ); + } +} diff --git a/tests/Helper/FormatTest.php b/tests/Helper/FormatTest.php index 85482c5..9a7d726 100644 --- a/tests/Helper/FormatTest.php +++ b/tests/Helper/FormatTest.php @@ -10,8 +10,8 @@ use stdClass; /** - * Unit tests for {@see Format} covering the megabyte readout, the trimmed CSS percentage formatter, and the value - * type labels. + * Unit tests for {@see Format} covering the megabyte readout, the trimmed CSS percentage formatter, the millisecond and + * relative-age labels, the wall-clock readout, and the value type labels. */ #[Group('helpers')] #[Group('format')] @@ -51,6 +51,111 @@ public function testCssPercentTrimsTrailingZerosAndDot(): void } } + public function testMillisecondsConvertsSecondsAndGroupsThousands(): void + { + self::assertSame( + '0 ms', + Format::milliseconds(0.0), + 'Zero seconds must render without decimals.', + ); + self::assertSame( + '123 ms', + Format::milliseconds(0.1234), + 'Sub-second values must scale to milliseconds.', + ); + self::assertSame( + '1,235 ms', + Format::milliseconds(1.2345), + 'Thousands must be grouped and the fraction rounded.', + ); + } + + public function testMillisecondsHonoursRequestedDecimals(): void + { + self::assertSame( + '12.5 ms', + Format::milliseconds(0.0125, 1), + 'Explicit precision must widen the decimals.', + ); + self::assertSame( + '12,500.000 ms', + Format::milliseconds(12.5, 3), + 'Grouping must survive a widened precision.', + ); + } + + public function testRelativeTimeKeepsBucketBoundariesExclusive(): void + { + self::assertSame( + 'just now', + Format::relativeTime(59, 'FALLBACK'), + 'Last second below a minute must stay in the first bucket.', + ); + self::assertSame( + 'FALLBACK', + Format::relativeTime(2_592_000, 'FALLBACK'), + 'Thirty days must switch to the absolute label.', + ); + } + + public function testRelativeTimeLabelsEachAgeBucket(): void + { + $cases = [ + 'just now' => 0, + '1 min ago' => 60, + '59 min ago' => 3599, + '1 h ago' => 3600, + '23 h ago' => 86399, + '1 d ago' => 86400, + '29 d ago' => 2591999, + ]; + + foreach ($cases as $expected => $elapsed) { + self::assertSame( + $expected, + Format::relativeTime($elapsed, 'FALLBACK'), + "Age of {$elapsed} s must read '{$expected}'.", + ); + } + } + + public function testTimeOfDayAppendsPaddedMillisecondFraction(): void + { + self::assertSame( + '22:13:20.123', + Format::timeOfDay(1_700_000_000_123), + 'Fraction must follow the second-precision part.', + ); + self::assertSame( + '22:13:20.007', + Format::timeOfDay(1_700_000_000_007), + 'Fraction must be zero-padded to three digits.', + ); + self::assertSame( + '22:13:20.000', + Format::timeOfDay(1_700_000_000_000), + 'Whole seconds must still carry a fraction.', + ); + } + + public function testTimeOfDayAppliesTheRequestedDateFormat(): void + { + self::assertSame( + '2023-11-14 22:13:20.123', + Format::timeOfDay(1_700_000_000_123, 'Y-m-d H:i:s'), + 'Custom format must prefix the fraction.', + ); + } + + public function testTimeOfDayNormalizesNegativeFractions(): void + { + self::assertSame( + '23:59:59.500', + Format::timeOfDay(-1_500), + 'Pre-epoch input must keep a three-digit unsigned fraction.', + ); + } + public function testTypeOfLabelsScalarsArraysStringsAndNull(): void { self::assertSame( diff --git a/tests/Helper/TableTest.php b/tests/Helper/TableTest.php new file mode 100644 index 0000000..f37bf00 --- /dev/null +++ b/tests/Helper/TableTest.php @@ -0,0 +1,156 @@ + +
+ HTML, + Table::build([], [])->render(), + 'Default class must be the shared debugger table class.', + ); + } + + public function testBuildKeepsTheSharedShellDecorableByTheCaller(): void + { + self::assertSame( + << + + + + Name + + + + + + 1 + + home + + + + + HTML, + Table::build(['Name'], self::rows(), 'yii-debug-table yii-debug-table-mono') + ->style(['table-layout' => 'fixed']) + ->render(), + 'Decorations must survive on the returned element.', + ); + } + + public function testRenderAcceptsCustomShellClasses(): void + { + self::assertSame( + << + + + + + + + +
+ # +
+ + HTML, + Table::render( + ['#'], + [], + 'yii-debug-table yii-debug-route-trace', + 'yii-debug-table-wrap yii-debug-route-trace-wrap', + ), + 'Both shell classes must reach the wrapper and the table.', + ); + } + + public function testRenderOmitsTheHeaderRowWithoutLabels(): void + { + self::assertSame( + << + + + + + + +
+ 1 + + home +
+ + HTML, + Table::render([], self::rows()), + 'An empty label list must skip the `thead` entirely.', + ); + } + + public function testRenderWrapsTheHeaderAndBodyRows(): void + { + self::assertSame( + << + + + + + + + + + + +
+ # + + Route +
+ 1 + + home +
+ + HTML, + Table::render(['#', 'Route'], self::rows()), + 'Column labels must render as scoped header cells.', + ); + } + + /** + * @return list + */ + private static function rows(): array + { + return [ + Tr::tag() + ->html( + Td::tag()->content('1'), + Td::tag()->content('home'), + ), + ]; + } +} diff --git a/tests/Panel/Asset/AssetCardRendererTest.php b/tests/Panel/Asset/AssetCardRendererTest.php index 6b989a6..6b2f11d 100644 --- a/tests/Panel/Asset/AssetCardRendererTest.php +++ b/tests/Panel/Asset/AssetCardRendererTest.php @@ -28,7 +28,7 @@ public function testRenderCardEmitsArticleWithBundleAnchorId(): void $bundle = $summary->bundles[0] ?? self::fail('Expected one bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertSame( <<bundles[0] ?? self::fail('Expected one bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertSame( <<bundles[0] ?? self::fail('Expected one bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertSame( <<bundles[0] ?? self::fail('Expected one bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertSame( <<bundles[0] ?? self::fail('Expected one bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertMatchesRegularExpression( '/>\s*AppAsset\s*bundles[0] ?? self::fail('Expected one bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertSame( <<bundles[0] ?? self::fail('Expected one bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertSame( <<bundles[0] ?? self::fail('Expected the source bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertSame( <<bundles[0] ?? self::fail('Expected one bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertSame( <<normalize( + self::rows( + [ + 'app\\AppAsset' => ['depends' => ['app\\OtherAsset']], + 'app\\OtherAsset' => [], + ], + ), + ); + + $bundle = $summary->bundles[0] ?? self::fail('Expected the source bundle.'); + $target = $summary->bundles[1] ?? self::fail('Expected a second bundle.'); + + $html = AssetCardRenderer::renderCard($bundle)->render(); + + self::assertStringContainsString( + 'href="#' . $target->id . '"', + $html, + 'Registered deps must resolve to the matching card id.', + ); + } + + public function testRenderCardResolvesUnregisteredDependencyAnchorThroughCamel2id(): void + { + $summary = (new AssetBundleNormalizer()) + ->normalize( + self::rows( + ['app\\AppAsset' => ['depends' => ['unknown\\package\\StrangerAsset']]], + ), + ); + + $bundle = $summary->bundles[0] ?? self::fail('Expected one bundle.'); + + $html = AssetCardRenderer::renderCard($bundle)->render(); + + self::assertStringContainsString( + 'href="#unknown\\package\\-stranger-asset"', + $html, + "Unregistered deps must use the same 'Inflector::camel2id()' rule as registered ones.", + ); + } + public function testRenderCardWiringRendersBasePathRow(): void { $summary = (new AssetBundleNormalizer()) @@ -412,7 +456,7 @@ public function testRenderCardWiringRendersBasePathRow(): void $bundle = $summary->bundles[0] ?? self::fail('Expected one bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertMatchesRegularExpression( '/>\s*base\s*bundles[0] ?? self::fail('Expected one bundle.'); - $html = AssetCardRenderer::renderCard($bundle, $summary)->render(); + $html = AssetCardRenderer::renderCard($bundle)->render(); self::assertMatchesRegularExpression( '/>\s*url\s*normalize( - self::rows(['app\\AppAsset' => []]), - ); - - self::assertSame( - 'unknown\\package\\-stranger-asset', - AssetCardRenderer::resolveAnchor('unknown\\package\\StrangerAsset', $summary), - "Unregistered deps must use the same 'Inflector::camel2id()' rule as registered ones.", - ); - } - - public function testResolveAnchorReturnsRegisteredBundleId(): void - { - $summary = (new AssetBundleNormalizer()) - ->normalize( - self::rows( - [ - 'app\\AppAsset' => [], - 'app\\OtherAsset' => [], - ], - ), - ); - - $bundle = $summary->bundles[1] ?? self::fail('Expected a second bundle.'); - - self::assertSame( - $bundle->id, - AssetCardRenderer::resolveAnchor('app\\OtherAsset', $summary), - 'Registered deps must resolve to the matching card id.', - ); - } - /** * @param array $bundles * diff --git a/tests/Panel/Db/DbExplainRendererTest.php b/tests/Panel/Db/DbExplainRendererTest.php index e9b6341..a7d6a7c 100644 --- a/tests/Panel/Db/DbExplainRendererTest.php +++ b/tests/Panel/Db/DbExplainRendererTest.php @@ -100,6 +100,43 @@ public function testRenderKeyedResultRowsAreReindexedBeforeColumnDetection(): vo ); } + public function testRenderNumericColumnKeysAsTextHeadings(): void + { + self::assertSame( + << +

+ EXPLAIN +

+            SELECT 1
+            
+ + + + + + + + + + +
+ 0 + + 1 +
+ seq + + detail +
+
+ + HTML, + DbExplainRenderer::render('SELECT 1', [[0 => 'seq', 1 => 'detail']]), + 'Positional driver columns must survive as text headings.', + ); + } + public function testRenderQueryAndResultTable(): void { self::assertSame( diff --git a/tests/Panel/Db/DbQueryRendererTest.php b/tests/Panel/Db/DbQueryRendererTest.php index 1b413e0..dacc744 100644 --- a/tests/Panel/Db/DbQueryRendererTest.php +++ b/tests/Panel/Db/DbQueryRendererTest.php @@ -17,33 +17,6 @@ #[Group('db')] final class DbQueryRendererTest extends TestCase { - public function testCanBeExplainedReturnsFalseForUnsupportedVerb(): void - { - self::assertFalse( - DbQueryRenderer::canBeExplained('PRAGMA'), - 'PRAGMA must not be marked as EXPLAIN-able.', - ); - self::assertFalse( - DbQueryRenderer::canBeExplained(''), - 'Empty verb must not be marked as EXPLAIN-able.', - ); - } - - public function testCanBeExplainedReturnsTrueForSupportedVerbs(): void - { - foreach (['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'WITH'] as $verb) { - self::assertTrue( - DbQueryRenderer::canBeExplained($verb), - "Verb '{$verb}' must be EXPLAIN-able.", - ); - self::assertTrue( - DbQueryRenderer::canBeExplained(strtolower($verb)), - "Verb '{$verb}' must be EXPLAIN-able regardless of case.", - ); - } - } - - public function testRenderDurationCellFormatsDurationToOneDecimalMillisecond(): void { self::assertSame( @@ -139,6 +112,24 @@ public function testRenderNPlusOneWorkflowLinksSummaryAndRows(): void self::assertStringContainsString('Potential N+1 · 4 similar', $query); } + public function testRenderQueryCellEmitsExplainToggleForExplainableVerbsRegardlessOfCase(): void + { + foreach (['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'WITH'] as $verb) { + foreach ([$verb, strtolower($verb)] as $type) { + self::assertStringContainsString( + 'yii-debug-db-explain-toggle', + DbQueryRenderer::renderQueryCell( + self::makeRow(type: $type), + self::traceLine(), + true, + self::makeUrlBuilder(), + ), + "Verb '{$type}' must be EXPLAIN-able.", + ); + } + } + } + public function testRenderQueryCellEmitsExplainToggleWithBuiltUrl(): void { $html = DbQueryRenderer::renderQueryCell( @@ -207,6 +198,22 @@ public function testRenderQueryCellEscapesQueryContent(): void } + public function testRenderQueryCellOmitsExplainToggleForNonExplainableVerbs(): void + { + foreach (['PRAGMA', ''] as $type) { + self::assertStringNotContainsString( + 'yii-debug-db-explain-toggle', + DbQueryRenderer::renderQueryCell( + self::makeRow(type: $type), + self::traceLine(), + true, + self::makeUrlBuilder(), + ), + "Verb '{$type}' must not be EXPLAIN-able.", + ); + } + } + public function testRenderQueryCellOmitsExplainToggleWhenHasExplainIsFalse(): void { $html = DbQueryRenderer::renderQueryCell( diff --git a/tests/Panel/Db/NPlusOneDetectorTest.php b/tests/Panel/Db/NPlusOneDetectorTest.php index 52f0edc..a5fae8f 100644 --- a/tests/Panel/Db/NPlusOneDetectorTest.php +++ b/tests/Panel/Db/NPlusOneDetectorTest.php @@ -159,10 +159,6 @@ public function testDuplicateCountsFromOtherCallSitesDoNotTriggerFinding(): void $finding->fingerprint, 'Global exact duplicates must not leak across callers.', ); - self::assertTrue( - $finding->contains($candidate), - 'Findings should identify rows from their fingerprint.', - ); } public function testRejectsUnsafeThreshold(): void diff --git a/tests/Panel/Event/EventMessageTest.php b/tests/Panel/Event/EventMessageTest.php index 8e68515..92e72f6 100644 --- a/tests/Panel/Event/EventMessageTest.php +++ b/tests/Panel/Event/EventMessageTest.php @@ -6,20 +6,16 @@ use PHPForge\Debug\Panel\Event\EventMessage; use PHPForge\Debug\Tests\Provider\EventMessageProvider; +use PHPForge\Debug\Tests\Support\MessageCatalogTestCase; use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; -use PHPUnit\Framework\TestCase; use UIAwesome\Html\Flow\P; -use UIAwesome\Html\Helper\Encode; - -use function array_column; -use function iterator_to_array; /** * Tests the {@see EventMessage} text catalog and direct enum content without changing rendering or escaping. */ #[Group('panel')] #[Group('event')] -final class EventMessageTest extends TestCase +final class EventMessageTest extends MessageCatalogTestCase { public function testCapturedValuesRemainEscapedAlongsideMessages(): void { @@ -34,41 +30,28 @@ public function testCapturedValuesRemainEscapedAlongsideMessages(): void ); } - public function testProviderCoversTheCompleteCatalog(): void + #[DataProviderExternal(EventMessageProvider::class, 'messages')] + public function testRendersMessageDirectlyAsContent(EventMessage $message, string $expected): void { - self::assertEqualsCanonicalizing( - EventMessage::cases(), - array_column(iterator_to_array(EventMessageProvider::messages()), 0), - 'Every catalog case must have an explicit wording and rendering regression test.', + self::assertRendersAsContent( + $message, + $expected, ); } - #[DataProviderExternal(EventMessageProvider::class, 'messages')] - public function testRendersMessageDirectlyAsContent(EventMessage $message, string $expected): void + /** + * @return list Cases of the catalog under test. + */ + protected function catalogCases(): array { - $paragraph = P::tag(); - - $rendered = $paragraph->content($message); + return EventMessage::cases(); + } - self::assertSame( - $expected, - $message->value, - 'The catalog must preserve the existing panel wording.', - ); - self::assertSame( - "

\n" . Encode::content($expected) . "\n

", - $rendered->render(), - 'Content must accept the enum case without extracting its value.', - ); - self::assertNotSame( - $paragraph, - $rendered, - 'Enum content must preserve immutable tag construction.', - ); - self::assertSame( - '', - $paragraph->getContent(), - 'Rendering a message must not mutate the original tag.', - ); + /** + * @return iterable Provider rows that drive the rendering test. + */ + protected function catalogProvider(): iterable + { + return EventMessageProvider::messages(); } } diff --git a/tests/Panel/Inertia/InertiaMessageTest.php b/tests/Panel/Inertia/InertiaMessageTest.php index 8476654..918afa5 100644 --- a/tests/Panel/Inertia/InertiaMessageTest.php +++ b/tests/Panel/Inertia/InertiaMessageTest.php @@ -6,20 +6,16 @@ use PHPForge\Debug\Panel\Inertia\InertiaMessage; use PHPForge\Debug\Tests\Provider\InertiaMessageProvider; +use PHPForge\Debug\Tests\Support\MessageCatalogTestCase; use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; -use PHPUnit\Framework\TestCase; use UIAwesome\Html\Flow\P; -use UIAwesome\Html\Helper\Encode; - -use function array_column; -use function iterator_to_array; /** * Tests the {@see InertiaMessage} text catalog and direct enum content without changing rendering or escaping. */ #[Group('panel')] #[Group('inertia')] -final class InertiaMessageTest extends TestCase +final class InertiaMessageTest extends MessageCatalogTestCase { public function testCapturedValuesRemainEscapedAlongsideMessages(): void { @@ -34,41 +30,28 @@ public function testCapturedValuesRemainEscapedAlongsideMessages(): void ); } - public function testProviderCoversTheCompleteCatalog(): void + #[DataProviderExternal(InertiaMessageProvider::class, 'messages')] + public function testRendersMessageDirectlyAsContent(InertiaMessage $message, string $expected): void { - self::assertEqualsCanonicalizing( - InertiaMessage::cases(), - array_column(iterator_to_array(InertiaMessageProvider::messages()), 0), - 'Every catalog case must have an explicit wording and rendering regression test.', + self::assertRendersAsContent( + $message, + $expected, ); } - #[DataProviderExternal(InertiaMessageProvider::class, 'messages')] - public function testRendersMessageDirectlyAsContent(InertiaMessage $message, string $expected): void + /** + * @return list Cases of the catalog under test. + */ + protected function catalogCases(): array { - $paragraph = P::tag(); - - $rendered = $paragraph->content($message); + return InertiaMessage::cases(); + } - self::assertSame( - $expected, - $message->value, - 'The catalog must preserve the existing panel wording.', - ); - self::assertSame( - "

\n" . Encode::content($expected) . "\n

", - $rendered->render(), - 'Content must accept the enum case without extracting its value.', - ); - self::assertNotSame( - $paragraph, - $rendered, - 'Enum content must preserve immutable tag construction.', - ); - self::assertSame( - '', - $paragraph->getContent(), - 'Rendering a message must not mutate the original tag.', - ); + /** + * @return iterable Provider rows that drive the rendering test. + */ + protected function catalogProvider(): iterable + { + return InertiaMessageProvider::messages(); } } diff --git a/tests/Panel/Log/LogMessageTest.php b/tests/Panel/Log/LogMessageTest.php index 305f8e2..85329dd 100644 --- a/tests/Panel/Log/LogMessageTest.php +++ b/tests/Panel/Log/LogMessageTest.php @@ -6,20 +6,16 @@ use PHPForge\Debug\Panel\Log\LogMessage; use PHPForge\Debug\Tests\Provider\LogMessageProvider; +use PHPForge\Debug\Tests\Support\MessageCatalogTestCase; use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; -use PHPUnit\Framework\TestCase; use UIAwesome\Html\Flow\P; -use UIAwesome\Html\Helper\Encode; - -use function array_column; -use function iterator_to_array; /** * Tests the {@see LogMessage} text catalog and direct enum content without changing rendering or escaping. */ #[Group('panel')] #[Group('log')] -final class LogMessageTest extends TestCase +final class LogMessageTest extends MessageCatalogTestCase { public function testCapturedValuesRemainEscapedAlongsideMessages(): void { @@ -34,41 +30,28 @@ public function testCapturedValuesRemainEscapedAlongsideMessages(): void ); } - public function testProviderCoversTheCompleteCatalog(): void + #[DataProviderExternal(LogMessageProvider::class, 'messages')] + public function testRendersMessageDirectlyAsContent(LogMessage $message, string $expected): void { - self::assertEqualsCanonicalizing( - LogMessage::cases(), - array_column(iterator_to_array(LogMessageProvider::messages()), 0), - 'Every catalog case must have an explicit wording and rendering regression test.', + self::assertRendersAsContent( + $message, + $expected, ); } - #[DataProviderExternal(LogMessageProvider::class, 'messages')] - public function testRendersMessageDirectlyAsContent(LogMessage $message, string $expected): void + /** + * @return list Cases of the catalog under test. + */ + protected function catalogCases(): array { - $paragraph = P::tag(); - - $rendered = $paragraph->content($message); + return LogMessage::cases(); + } - self::assertSame( - $expected, - $message->value, - 'The catalog must preserve the existing panel wording.', - ); - self::assertSame( - "

\n" . Encode::content($expected) . "\n

", - $rendered->render(), - 'Content must accept the enum case without extracting its value.', - ); - self::assertNotSame( - $paragraph, - $rendered, - 'Enum content must preserve immutable tag construction.', - ); - self::assertSame( - '', - $paragraph->getContent(), - 'Rendering a message must not mutate the original tag.', - ); + /** + * @return iterable Provider rows that drive the rendering test. + */ + protected function catalogProvider(): iterable + { + return LogMessageProvider::messages(); } } diff --git a/tests/Panel/PanelMessageTest.php b/tests/Panel/PanelMessageTest.php index 3c72654..73e8d75 100644 --- a/tests/Panel/PanelMessageTest.php +++ b/tests/Panel/PanelMessageTest.php @@ -6,19 +6,15 @@ use PHPForge\Debug\Panel\PanelMessage; use PHPForge\Debug\Tests\Provider\PanelMessageProvider; +use PHPForge\Debug\Tests\Support\MessageCatalogTestCase; use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; -use PHPUnit\Framework\TestCase; use UIAwesome\Html\Flow\P; -use UIAwesome\Html\Helper\Encode; - -use function array_column; -use function iterator_to_array; /** - * Tests the panel text catalog and direct enum content without changing rendering or escaping. + * Tests the {@see PanelMessage} text catalog and direct enum content without changing rendering or escaping. */ #[Group('panel')] -final class PanelMessageTest extends TestCase +final class PanelMessageTest extends MessageCatalogTestCase { public function testCapturedValuesRemainEscapedAlongsideMessages(): void { @@ -33,41 +29,28 @@ public function testCapturedValuesRemainEscapedAlongsideMessages(): void ); } - public function testProviderCoversTheCompleteCatalog(): void + #[DataProviderExternal(PanelMessageProvider::class, 'messages')] + public function testRendersMessageDirectlyAsContent(PanelMessage $message, string $expected): void { - self::assertEqualsCanonicalizing( - PanelMessage::cases(), - array_column(iterator_to_array(PanelMessageProvider::messages()), 0), - 'Every catalog case must have an explicit wording and rendering regression test.', + self::assertRendersAsContent( + $message, + $expected, ); } - #[DataProviderExternal(PanelMessageProvider::class, 'messages')] - public function testRendersMessageDirectlyAsContent(PanelMessage $message, string $expected): void + /** + * @return list Cases of the catalog under test. + */ + protected function catalogCases(): array { - $paragraph = P::tag(); - - $rendered = $paragraph->content($message); + return PanelMessage::cases(); + } - self::assertSame( - $expected, - $message->value, - 'The catalog must preserve the existing panel wording.', - ); - self::assertSame( - "

\n" . Encode::content($expected) . "\n

", - $rendered->render(), - 'Content must accept the enum case without extracting its value.', - ); - self::assertNotSame( - $paragraph, - $rendered, - 'Enum content must preserve immutable tag construction.', - ); - self::assertSame( - '', - $paragraph->getContent(), - 'Rendering a message must not mutate the original tag.', - ); + /** + * @return iterable Provider rows that drive the rendering test. + */ + protected function catalogProvider(): iterable + { + return PanelMessageProvider::messages(); } } diff --git a/tests/Panel/PanelRenderContextTest.php b/tests/Panel/PanelRenderContextTest.php index 6d8f97b..1088e42 100644 --- a/tests/Panel/PanelRenderContextTest.php +++ b/tests/Panel/PanelRenderContextTest.php @@ -18,7 +18,7 @@ #[Group('routing')] final class PanelRenderContextTest extends TestCase { - public function testBuildsUrlsWithCurrentContextByDefault(): void + public function testBuildsPanelUrlWithCurrentContextByDefault(): void { $context = new PanelRenderContext( 'request-1', @@ -28,24 +28,14 @@ public function testBuildsUrlsWithCurrentContextByDefault(): void self::urlGenerator(), ); - self::assertSame( - '/history?Log%5Blevel%5D=error', - $context->historyUrl(), - 'History links must receive the current query parameters.', - ); self::assertSame( '/panel/request-1/log?Log%5Blevel%5D=error', $context->panelUrl(), 'Panel links must receive the current tag, panel, and query parameters.', ); - self::assertSame( - '/action/download/request-1?Log%5Blevel%5D=error', - $context->actionUrl('download'), - 'Action links must receive the current tag and query parameters.', - ); } - public function testBuildsUrlsWithExplicitTargetsAndParameters(): void + public function testBuildsPanelUrlWithExplicitTargetsAndParameters(): void { $context = new PanelRenderContext( 'request-1', @@ -55,21 +45,11 @@ public function testBuildsUrlsWithExplicitTargetsAndParameters(): void self::urlGenerator(), ); - self::assertSame( - '/history', - $context->historyUrl([]), - 'An explicit empty query must clear history state.', - ); self::assertSame( '/panel/request-1/db?Db%5Btype%5D=SELECT', $context->panelUrl('db', ['Db' => ['type' => 'SELECT']]), 'Panel links must accept a different panel and query.', ); - self::assertSame( - '/action/explain/request-1?seq=3', - $context->actionUrl('explain', ['seq' => 3]), - 'Action links must accept an explicit query.', - ); } public function testReturnsCrossPanelPayloadWhenCaptured(): void @@ -99,26 +79,10 @@ public function testReturnsCrossPanelPayloadWhenCaptured(): void private static function urlGenerator(): DebugUrlGeneratorInterface { return new class implements DebugUrlGeneratorInterface { - public function action(string $action, string $tag, array $queryParams = []): string - { - return self::url("/action/{$action}/{$tag}", $queryParams); - } - - public function history(array $queryParams = []): string - { - return self::url('/history', $queryParams); - } - public function panel(string $tag, string $panel, array $queryParams = []): string { - return self::url("/panel/{$tag}/{$panel}", $queryParams); - } + $path = "/panel/{$tag}/{$panel}"; - /** - * @param array $queryParams - */ - private static function url(string $path, array $queryParams): string - { return $queryParams === [] ? $path : $path . '?' . http_build_query($queryParams); } }; diff --git a/tests/Panel/Profile/ProfileMessageTest.php b/tests/Panel/Profile/ProfileMessageTest.php index fd24241..9178424 100644 --- a/tests/Panel/Profile/ProfileMessageTest.php +++ b/tests/Panel/Profile/ProfileMessageTest.php @@ -6,20 +6,16 @@ use PHPForge\Debug\Panel\Profile\ProfileMessage; use PHPForge\Debug\Tests\Provider\ProfileMessageProvider; +use PHPForge\Debug\Tests\Support\MessageCatalogTestCase; use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; -use PHPUnit\Framework\TestCase; use UIAwesome\Html\Flow\P; -use UIAwesome\Html\Helper\Encode; - -use function array_column; -use function iterator_to_array; /** * Tests the {@see ProfileMessage} text catalog and direct enum content without changing rendering or escaping. */ #[Group('panel')] #[Group('profile')] -final class ProfileMessageTest extends TestCase +final class ProfileMessageTest extends MessageCatalogTestCase { public function testCapturedValuesRemainEscapedAlongsideMessages(): void { @@ -34,41 +30,28 @@ public function testCapturedValuesRemainEscapedAlongsideMessages(): void ); } - public function testProviderCoversTheCompleteCatalog(): void + #[DataProviderExternal(ProfileMessageProvider::class, 'messages')] + public function testRendersMessageDirectlyAsContent(ProfileMessage $message, string $expected): void { - self::assertEqualsCanonicalizing( - ProfileMessage::cases(), - array_column(iterator_to_array(ProfileMessageProvider::messages()), 0), - 'Every catalog case must have an explicit wording and rendering regression test.', + self::assertRendersAsContent( + $message, + $expected, ); } - #[DataProviderExternal(ProfileMessageProvider::class, 'messages')] - public function testRendersMessageDirectlyAsContent(ProfileMessage $message, string $expected): void + /** + * @return list Cases of the catalog under test. + */ + protected function catalogCases(): array { - $paragraph = P::tag(); - - $rendered = $paragraph->content($message); + return ProfileMessage::cases(); + } - self::assertSame( - $expected, - $message->value, - 'The catalog must preserve the existing panel wording.', - ); - self::assertSame( - "

\n" . Encode::content($expected) . "\n

", - $rendered->render(), - 'Content must accept the enum case without extracting its value.', - ); - self::assertNotSame( - $paragraph, - $rendered, - 'Enum content must preserve immutable tag construction.', - ); - self::assertSame( - '', - $paragraph->getContent(), - 'Rendering a message must not mutate the original tag.', - ); + /** + * @return iterable Provider rows that drive the rendering test. + */ + protected function catalogProvider(): iterable + { + return ProfileMessageProvider::messages(); } } diff --git a/tests/Panel/Queue/QueueCardRendererTest.php b/tests/Panel/Queue/QueueCardRendererTest.php index e65aa11..72427ac 100644 --- a/tests/Panel/Queue/QueueCardRendererTest.php +++ b/tests/Panel/Queue/QueueCardRendererTest.php @@ -4,7 +4,8 @@ namespace PHPForge\Debug\Tests\Panel\Queue; -use PHPForge\Debug\Panel\Queue\{JobRecord, QueueCardRenderer, QueueSummary}; +use PHPForge\Debug\Panel\Queue\{QueueCardRenderer, QueueSummary}; +use PHPForge\Debug\Tests\Support\JobRecordFixture; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -20,9 +21,9 @@ public function testRenderAsyncHintListsDistinctAsyncDrivers(): void { $summary = new QueueSummary( [ - self::makeRecord(driverName: 'AMQP', isAsync: true), - self::makeRecord(driverName: 'Redis', isAsync: true), - self::makeRecord(driverName: 'AMQP', isAsync: true), + JobRecordFixture::create(driverName: 'AMQP', isAsync: true), + JobRecordFixture::create(driverName: 'Redis', isAsync: true), + JobRecordFixture::create(driverName: 'AMQP', isAsync: true), ], ); $hint = QueueCardRenderer::renderAsyncHint($summary); @@ -47,9 +48,7 @@ public function testRenderAsyncHintListsDistinctAsyncDrivers(): void public function testRenderAsyncHintReturnsNullWhenAllRecordsAreSync(): void { - $summary = new QueueSummary( - [self::makeRecord(driverName: 'Sync', isAsync: false)], - ); + $summary = new QueueSummary([JobRecordFixture::create(driverName: 'Sync', isAsync: false)]); self::assertNull( QueueCardRenderer::renderAsyncHint($summary), @@ -60,10 +59,10 @@ public function testRenderAsyncHintReturnsNullWhenAllRecordsAreSync(): void public function testRenderItemDriverPillUsesTheDriverClassOrFallbackTooltip(): void { $known = QueueCardRenderer::renderItem( - self::makeRecord(driverName: 'Redis', driverClass: 'yii\\queue\\redis\\Queue'), + JobRecordFixture::create(driverName: 'Redis', driverClass: 'yii\\queue\\redis\\Queue'), )->render(); $unknown = QueueCardRenderer::renderItem( - self::makeRecord(driverName: 'Custom', driverClass: ''), + JobRecordFixture::create(driverName: 'Custom', driverClass: ''), )->render(); self::assertSame( @@ -104,7 +103,7 @@ public function testRenderItemDriverPillUsesTheDriverClassOrFallbackTooltip(): v public function testRenderItemEmitsCardWithClassAndStatusPill(): void { - $record = self::makeRecord(jobClass: 'app\\jobs\\HelloJob', eventType: 'push'); + $record = JobRecordFixture::create(jobClass: 'app\\jobs\\HelloJob', eventType: 'push'); self::assertSame( <<
HTML, - QueueCardRenderer::renderItem(self::makeRecord(eventType: 'error'))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(eventType: 'error'))->render(), "Error event must use the 'failed' status variant.", ); } @@ -162,7 +161,7 @@ public function testRenderItemMapsExecToDoneStatusVariant(): void HTML, - QueueCardRenderer::renderItem(self::makeRecord(eventType: 'exec'))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(eventType: 'exec'))->render(), "Exec event must use the 'done' status variant.", ); @@ -184,7 +183,7 @@ public function testRenderItemOmitsComponentIdFromMetaStrip(): void HTML, - QueueCardRenderer::renderItem(self::makeRecord(componentId: 'queueEmail'))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(componentId: 'queueEmail'))->render(), 'Component meta item must be hidden the sidebar/tab strip surfaces it instead.', ); } @@ -205,7 +204,7 @@ public function testRenderItemOmitsDriverPillWhenDriverNameIsEmpty(): void HTML, - QueueCardRenderer::renderItem(self::makeRecord(driverName: ''))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(driverName: ''))->render(), 'Empty driver name must hide the driver pill.', ); } @@ -226,7 +225,7 @@ public function testRenderItemOmitsMetaWhenNoOptionalFieldsPresent(): void HTML, - QueueCardRenderer::renderItem(self::makeRecord())->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create())->render(), 'No optional fields must omit the meta strip.', ); } @@ -247,16 +246,16 @@ public function testRenderItemOmitsPayloadBlockWhenFieldsAreEmpty(): void HTML, - QueueCardRenderer::renderItem(self::makeRecord(payloadFields: []))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(payloadFields: []))->render(), 'Empty payload fields must omit the block.', ); } public function testRenderItemRendersAvatarHueDeterministicallyFromJobClass(): void { - $first = QueueCardRenderer::renderItem(self::makeRecord(jobClass: 'app\\jobs\\Hello'))->render(); - $second = QueueCardRenderer::renderItem(self::makeRecord(jobClass: 'app\\jobs\\Hello'))->render(); - $third = QueueCardRenderer::renderItem(self::makeRecord(jobClass: 'app\\jobs\\World'))->render(); + $first = QueueCardRenderer::renderItem(JobRecordFixture::create(jobClass: 'app\\jobs\\Hello'))->render(); + $second = QueueCardRenderer::renderItem(JobRecordFixture::create(jobClass: 'app\\jobs\\Hello'))->render(); + $third = QueueCardRenderer::renderItem(JobRecordFixture::create(jobClass: 'app\\jobs\\World'))->render(); self::assertSame( self::extractHue($first), @@ -273,7 +272,7 @@ public function testRenderItemRendersAvatarHueDeterministicallyFromJobClass(): v public function testRenderItemRendersCollapsibleBlockForNestedObjects(): void { $html = QueueCardRenderer::renderItem( - self::makeRecord( + JobRecordFixture::create( payloadFields: [ 'inner' => [ '__class' => 'app\\models\\Inner', @@ -317,7 +316,7 @@ public function testRenderItemRendersCollapsibleBlockForNestedObjects(): void public function testRenderItemRendersCollapsibleBlockForRegularArray(): void { $html = QueueCardRenderer::renderItem( - self::makeRecord( + JobRecordFixture::create( payloadFields: [ 'items' => [ 'a', @@ -379,7 +378,7 @@ public function testRenderItemRendersDriverPillWithName(): void HTML, - QueueCardRenderer::renderItem(self::makeRecord(driverName: 'AMQP', isAsync: true))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(driverName: 'AMQP', isAsync: true))->render(), "Async driver must use the 'is-async' modifier.", ); } @@ -387,14 +386,10 @@ public function testRenderItemRendersDriverPillWithName(): void public function testRenderItemRendersErrorBlockOnlyWhenErrorMessagePresent(): void { $withError = QueueCardRenderer::renderItem( - self::makeRecord( - eventType: 'error', - error: 'Boom: something failed', - ), + JobRecordFixture::create(eventType: 'error', error: 'Boom: something failed'), )->render(); - $withoutError = QueueCardRenderer::renderItem(self::makeRecord()) - ->render(); + $withoutError = QueueCardRenderer::renderItem(JobRecordFixture::create())->render(); self::assertSame( << HTML, - QueueCardRenderer::renderItem(self::makeRecord(jobClass: ''))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(jobClass: ''))->render(), "Empty class name must fall back to hue '210'.", ); } @@ -458,7 +453,7 @@ public function testRenderItemRendersFallbackInitialAndHueWhenJobClassIsEmpty(): public function testRenderItemRendersMetaItemsWhenOptionalFieldsPresent(): void { $html = QueueCardRenderer::renderItem( - self::makeRecord( + JobRecordFixture::create( jobId: 'msg-7', ttr: 30, delay: 5, @@ -492,7 +487,7 @@ public function testRenderItemRendersMetaItemsWhenOptionalFieldsPresent(): void public function testRenderItemRendersPayloadTreeWhenFieldsPresent(): void { $html = QueueCardRenderer::renderItem( - self::makeRecord( + JobRecordFixture::create( payloadFields: [ 'message' => 'first', 'priority' => 5, @@ -546,7 +541,7 @@ public function testRenderItemRendersSyncDriverPillWithSyncModifier(): void HTML, - QueueCardRenderer::renderItem(self::makeRecord(driverName: 'Sync', isAsync: false))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(driverName: 'Sync', isAsync: false))->render(), "Sync driver must use the 'is-sync' modifier.", ); } @@ -554,7 +549,7 @@ public function testRenderItemRendersSyncDriverPillWithSyncModifier(): void public function testRenderItemRendersTruncatedMarkerInCollapsibleBlocks(): void { $html = QueueCardRenderer::renderItem( - self::makeRecord( + JobRecordFixture::create( payloadFields: [ 'items' => [ 'a', @@ -598,7 +593,7 @@ public function testRenderItemRendersTruncatedMarkerInCollapsibleBlocks(): void public function testRenderItemRendersTypeLabelsForEachScalarKind(): void { $html = QueueCardRenderer::renderItem( - self::makeRecord( + JobRecordFixture::create( payloadFields: [ 'msg' => 'x', 'count' => 10, @@ -655,7 +650,7 @@ public function testRenderItemRendersTypeLabelsForEachScalarKind(): void public function testRenderItemRendersUnsupportedRowForNonRenderableValues(): void { $html = QueueCardRenderer::renderItem( - self::makeRecord( + JobRecordFixture::create( payloadFields: [ 'handle' => fopen('php://memory', 'rb'), ], @@ -705,7 +700,7 @@ public function testRenderItemSkipsZeroDelayMetaItem(): void HTML, - QueueCardRenderer::renderItem(self::makeRecord(jobId: 'msg-1', delay: 0))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(jobId: 'msg-1', delay: 0))->render(), 'Zero delay must be hidden only positive delays render.', ); } @@ -734,7 +729,7 @@ public function testRenderItemTruncatesLongStringValuesAndKeepsFullValueInTitle( HTML, - QueueCardRenderer::renderItem(self::makeRecord(payloadFields: ['data' => $longValue]))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(payloadFields: ['data' => $longValue]))->render(), 'Long strings must be truncated with an ellipsis.', ); @@ -765,7 +760,7 @@ public function testRenderItemTruncatesStringsAtUnicodeCharacterBoundaries(): vo HTML, - QueueCardRenderer::renderItem(self::makeRecord(payloadFields: ['data' => $exactValue]))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(payloadFields: ['data' => $exactValue]))->render(), 'Exactly 80 characters must not truncate.', ); self::assertSame( @@ -788,7 +783,7 @@ public function testRenderItemTruncatesStringsAtUnicodeCharacterBoundaries(): vo HTML, - QueueCardRenderer::renderItem(self::makeRecord(payloadFields: ['data' => $longValue]))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(payloadFields: ['data' => $longValue]))->render(), 'Long Unicode strings must preserve the first 80 characters.', ); } @@ -809,7 +804,7 @@ public function testRenderItemUsesOneUnicodeCharacterForTheAvatarInitial(): void HTML, - QueueCardRenderer::renderItem(self::makeRecord(jobClass: 'app\\jobs\\éclairJob'))->render(), + QueueCardRenderer::renderItem(JobRecordFixture::create(jobClass: 'app\\jobs\\éclairJob'))->render(), 'Avatar initial must be one complete Unicode character.', ); } @@ -825,43 +820,4 @@ private static function extractHue(string $html): int self::fail('No avatar hue found in rendered HTML.'); } - - /** - * @param array $payloadFields - */ - private static function makeRecord( - string $eventType = 'push', - string $componentId = 'queue', - string $driverName = 'Sync', - string $driverClass = 'yii\\queue\\sync\\Queue', - bool $isAsync = false, - string $jobClass = 'app\\jobs\\HelloJob', - array $payloadFields = [], - float $time = 0.0, - string $jobId = '', - int|null $ttr = null, - int|null $delay = null, - int|null $priority = null, - int|null $attempt = null, - float|null $duration = null, - string $error = '', - ): JobRecord { - return new JobRecord( - eventType: $eventType, - componentId: $componentId, - driverName: $driverName, - driverClass: $driverClass, - isAsync: $isAsync, - jobClass: $jobClass, - payloadFields: $payloadFields, - time: $time, - jobId: $jobId, - ttr: $ttr, - delay: $delay, - priority: $priority, - attempt: $attempt, - duration: $duration, - error: $error, - ); - } } diff --git a/tests/Panel/Queue/QueueGridRendererTest.php b/tests/Panel/Queue/QueueGridRendererTest.php index b868189..a056654 100644 --- a/tests/Panel/Queue/QueueGridRendererTest.php +++ b/tests/Panel/Queue/QueueGridRendererTest.php @@ -4,7 +4,8 @@ namespace PHPForge\Debug\Tests\Panel\Queue; -use PHPForge\Debug\Panel\Queue\{JobRecord, QueueGridRenderer}; +use PHPForge\Debug\Panel\Queue\QueueGridRenderer; +use PHPForge\Debug\Tests\Support\JobRecordFixture; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -19,7 +20,7 @@ public function testRenderAttemptCellFormatsAsHashedNumber(): void { self::assertSame( '#3', - QueueGridRenderer::renderAttemptCell(self::makeRecord(attempt: 3)), + QueueGridRenderer::renderAttemptCell(JobRecordFixture::create(attempt: 3)), "Non-zero attempt must render as '#N'.", ); } @@ -28,13 +29,13 @@ public function testRenderAttemptCellReturnsDashWhenAttemptIsNullOrZero(): void { self::assertSame( '—', - QueueGridRenderer::renderAttemptCell(self::makeRecord(attempt: null)), + QueueGridRenderer::renderAttemptCell(JobRecordFixture::create(attempt: null)), "'null' attempt must yield '—'.", ); self::assertSame( '—', - QueueGridRenderer::renderAttemptCell(self::makeRecord(attempt: 0)), + QueueGridRenderer::renderAttemptCell(JobRecordFixture::create(attempt: 0)), "Zero attempt must yield '—'.", ); } @@ -43,7 +44,7 @@ public function testRenderComponentCellReturnsRawComponentId(): void { self::assertSame( 'queueRedis', - QueueGridRenderer::renderComponentCell(self::makeRecord(componentId: 'queueRedis')), + QueueGridRenderer::renderComponentCell(JobRecordFixture::create(componentId: 'queueRedis')), 'Component cell must echo the component id verbatim.', ); } @@ -54,7 +55,7 @@ public function testRenderDriverCellAddsAsyncModifier(): void <<Redis HTML, - QueueGridRenderer::renderDriverCell(self::makeRecord(driverName: 'Redis', isAsync: true)), + QueueGridRenderer::renderDriverCell(JobRecordFixture::create(driverName: 'Redis', isAsync: true)), "Async drivers must carry the 'is-async' modifier.", ); @@ -67,7 +68,7 @@ public function testRenderDriverCellAddsSyncModifierWhenInProcess(): void <<Sync HTML, - QueueGridRenderer::renderDriverCell(self::makeRecord(driverName: 'Sync', isAsync: false)), + QueueGridRenderer::renderDriverCell(JobRecordFixture::create(driverName: 'Sync', isAsync: false)), "Sync drivers must carry the 'is-sync' modifier.", ); } @@ -76,7 +77,7 @@ public function testRenderDriverCellReturnsEmptyWhenDriverNameIsMissing(): void { self::assertSame( '', - QueueGridRenderer::renderDriverCell(self::makeRecord(driverName: '')), + QueueGridRenderer::renderDriverCell(JobRecordFixture::create(driverName: '')), 'Empty driver name must yield an empty cell.', ); } @@ -87,7 +88,7 @@ public function testRenderDriverCellUsesFallbackTooltipWhenDriverClassIsMissing( <<Custom HTML, - QueueGridRenderer::renderDriverCell(self::makeRecord(driverName: 'Custom', driverClass: '')), + QueueGridRenderer::renderDriverCell(JobRecordFixture::create(driverName: 'Custom', driverClass: '')), 'Missing driver class must use the explicit fallback tooltip.', ); } @@ -96,12 +97,12 @@ public function testRenderDurationCellFormatsMilliseconds(): void { self::assertSame( '12.3 ms', - QueueGridRenderer::renderDurationCell(self::makeRecord(duration: 0.0123)), + QueueGridRenderer::renderDurationCell(JobRecordFixture::create(duration: 0.0123)), "Seconds must be formatted as 'XX.X ms'.", ); self::assertSame( '1,000.0 ms', - QueueGridRenderer::renderDurationCell(self::makeRecord(duration: 1.0)), + QueueGridRenderer::renderDurationCell(JobRecordFixture::create(duration: 1.0)), 'One second must convert using exactly one thousand milliseconds.', ); } @@ -110,7 +111,7 @@ public function testRenderDurationCellReturnsDashWhenDurationIsNull(): void { self::assertSame( '—', - QueueGridRenderer::renderDurationCell(self::makeRecord(duration: null)), + QueueGridRenderer::renderDurationCell(JobRecordFixture::create(duration: null)), "Missing duration must yield '—'.", ); } @@ -119,7 +120,7 @@ public function testRenderIdCellReturnsDashWhenJobIdIsEmpty(): void { self::assertSame( '—', - QueueGridRenderer::renderIdCell(self::makeRecord(jobId: '')), + QueueGridRenderer::renderIdCell(JobRecordFixture::create(jobId: '')), "Empty job id must yield '—' to keep the column readable.", ); } @@ -130,7 +131,7 @@ public function testRenderIdCellWrapsJobIdInTagLinkSpan(): void <<69ffbbf2a6830 HTML, - QueueGridRenderer::renderIdCell(self::makeRecord(jobId: '69ffbbf2a6830')), + QueueGridRenderer::renderIdCell(JobRecordFixture::create(jobId: '69ffbbf2a6830')), 'Id must reuse the History tag-link styling.', ); @@ -145,7 +146,7 @@ public function testRenderJobCellSplitsFqcnAndWiresHref(): void HTML, QueueGridRenderer::renderJobCell( - self::makeRecord(jobClass: 'app\\jobs\\HelloJob'), + JobRecordFixture::create(jobClass: 'app\\jobs\\HelloJob'), '/debug/queue?seq=2', ), 'Short class name must render in bold inside the link.', @@ -158,7 +159,7 @@ public function testRenderStatusCellRendersFailedVariantForErrorEvents(): void <<Failed HTML, - QueueGridRenderer::renderStatusCell(self::makeRecord(eventType: 'error')), + QueueGridRenderer::renderStatusCell(JobRecordFixture::create(eventType: 'error')), "Error events must produce the 'failed' modifier.", ); } @@ -169,7 +170,7 @@ public function testRenderStatusCellRendersQueuedVariantForPushEvents(): void <<<'HTML' Queued HTML, - QueueGridRenderer::renderStatusCell(self::makeRecord(eventType: 'push')), + QueueGridRenderer::renderStatusCell(JobRecordFixture::create(eventType: 'push')), "Push events must produce the 'queued' modifier.", ); @@ -179,7 +180,7 @@ public function testRenderTimeCellFormatsMicrotimeAsHmsWithMilliseconds(): void { self::assertSame( date('H:i:s', 1_704_112_496) . '.789', - QueueGridRenderer::renderTimeCell(self::makeRecord(time: 1_704_112_496.789)), + QueueGridRenderer::renderTimeCell(JobRecordFixture::create(time: 1_704_112_496.789)), "Time cell must preserve the exact 'HH:MM:SS.mmm' value.", ); } @@ -188,7 +189,7 @@ public function testRenderTimeCellTruncatesSubMillisecondPrecision(): void { self::assertSame( date('H:i:s', 1_704_112_496) . '.123', - QueueGridRenderer::renderTimeCell(self::makeRecord(time: 1_704_112_496.1239)), + QueueGridRenderer::renderTimeCell(JobRecordFixture::create(time: 1_704_112_496.1239)), 'Sub-millisecond precision must truncate after three digits.', ); } @@ -197,7 +198,7 @@ public function testRenderTtrCellAppendsSecondsSuffix(): void { self::assertSame( '300s', - QueueGridRenderer::renderTtrCell(self::makeRecord(ttr: 300)), + QueueGridRenderer::renderTtrCell(JobRecordFixture::create(ttr: 300)), "Non-zero TTR must render as 'Ns'.", ); } @@ -206,52 +207,13 @@ public function testRenderTtrCellReturnsDashWhenTtrIsNullOrZero(): void { self::assertSame( '—', - QueueGridRenderer::renderTtrCell(self::makeRecord(ttr: null)), + QueueGridRenderer::renderTtrCell(JobRecordFixture::create(ttr: null)), "Null TTR must yield '—'.", ); self::assertSame( '—', - QueueGridRenderer::renderTtrCell(self::makeRecord(ttr: 0)), + QueueGridRenderer::renderTtrCell(JobRecordFixture::create(ttr: 0)), "Zero TTR must yield '—'.", ); } - - /** - * @param array $payloadFields - */ - private static function makeRecord( - string $eventType = 'push', - string $componentId = 'queue', - string $driverName = 'Sync', - string $driverClass = 'yii\\queue\\sync\\Queue', - bool $isAsync = false, - string $jobClass = 'app\\jobs\\HelloJob', - array $payloadFields = [], - float $time = 0.0, - string $jobId = '', - int|null $ttr = null, - int|null $delay = null, - int|null $priority = null, - int|null $attempt = null, - float|null $duration = null, - string $error = '', - ): JobRecord { - return new JobRecord( - eventType: $eventType, - componentId: $componentId, - driverName: $driverName, - driverClass: $driverClass, - isAsync: $isAsync, - jobClass: $jobClass, - payloadFields: $payloadFields, - time: $time, - jobId: $jobId, - ttr: $ttr, - delay: $delay, - priority: $priority, - attempt: $attempt, - duration: $duration, - error: $error, - ); - } } diff --git a/tests/Panel/Request/RequestDataNormalizerTest.php b/tests/Panel/Request/RequestDataNormalizerTest.php index edf2eb1..08470ad 100644 --- a/tests/Panel/Request/RequestDataNormalizerTest.php +++ b/tests/Panel/Request/RequestDataNormalizerTest.php @@ -5,7 +5,7 @@ namespace PHPForge\Debug\Tests\Panel\Request; use PHPForge\Debug\Panel\Request\RequestDataNormalizer; -use PHPForge\Debug\Storage\RequestSummary; +use PHPForge\Debug\Tests\Support\RequestSummaryFixture; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -252,7 +252,7 @@ public function testFromPanelDataPrefersCapturedGeneralMethodOverSummary(): void { $view = RequestDataNormalizer::fromPanelData( ['general' => ['method' => 'PATCH']], - self::summary(['method' => 'GET']), + RequestSummaryFixture::create(['method' => 'GET']), ); self::assertSame( @@ -266,7 +266,7 @@ public function testFromPanelDataPrefersPanelStatusCodeOverSummary(): void { $view = RequestDataNormalizer::fromPanelData( ['statusCode' => 201], - self::summary(['statusCode' => 500]), + RequestSummaryFixture::create(['statusCode' => 500]), ); self::assertSame( @@ -358,7 +358,7 @@ public function testFromPanelDataSurfacesIpTimeAndDurationFromSummary(): void { $view = RequestDataNormalizer::fromPanelData( [], - self::summary(['ip' => '127.0.0.1', 'time' => 1_704_112_496.0, 'processingTime' => 0.0125]), + RequestSummaryFixture::create(['ip' => '127.0.0.1', 'time' => 1_704_112_496.0, 'processingTime' => 0.0125]), ); self::assertSame( @@ -429,7 +429,7 @@ public function testFromPanelDataUsesExactMillisecondConversionAndOmitsZeroTimes { $view = RequestDataNormalizer::fromPanelData( [], - self::summary(['time' => 0.0, 'processingTime' => 1.0]), + RequestSummaryFixture::create(['time' => 0.0, 'processingTime' => 1.0]), ); self::assertSame( @@ -443,29 +443,4 @@ public function testFromPanelDataUsesExactMillisecondConversionAndOmitsZeroTimes 'One second must convert to exactly one thousand milliseconds.', ); } - - /** - * @param array $overrides - */ - private static function summary(array $overrides = []): RequestSummary - { - return RequestSummary::fromArray( - [ - 'tag' => 'tag-1', - 'url' => 'https://example.test/', - 'ajax' => false, - 'method' => 'GET', - 'ip' => '127.0.0.1', - 'time' => 1_700_000_000.0, - 'statusCode' => 200, - 'sqlCount' => 0, - 'excessiveCallersCount' => 0, - 'mailCount' => 0, - 'mailFiles' => [], - 'processingTime' => null, - 'peakMemory' => null, - ...$overrides, - ], - ); - } } diff --git a/tests/Panel/Request/RequestDiagnosticLedgerTest.php b/tests/Panel/Request/RequestDiagnosticLedgerTest.php new file mode 100644 index 0000000..251316a --- /dev/null +++ b/tests/Panel/Request/RequestDiagnosticLedgerTest.php @@ -0,0 +1,73 @@ + +
+
+ Accept +
+ text/html +
+
+ + HTML, + RequestDiagnosticLedger::render( + 'yii-debug-server-ledger', + RequestDiagnosticLedger::row('Accept', 'text/html'), + ), + 'Ledger must carry the shared class plus the pane modifier.', + ); + } + + public function testRowAppendsTheModifierAfterTheSharedRowClass(): void + { + self::assertSame( + << +
+ Raw header line 0 +
+ HTTP/1.1 200 OK +
+ + HTML, + RequestDiagnosticLedger::row('Raw header line 0', 'HTTP/1.1 200 OK', 'yii-debug-header-raw-row')->render(), + 'Modifier must follow the shared row class.', + ); + } + + public function testRowStaysFilterableWithoutAModifier(): void + { + self::assertSame( + << +
+ Accept +
+ text/html +
+ + HTML, + RequestDiagnosticLedger::row('Accept', 'text/html')->render(), + 'Row must carry the filter hook and the shared class only.', + ); + } +} diff --git a/tests/Panel/Request/RequestHeadersRendererTest.php b/tests/Panel/Request/RequestHeadersRendererTest.php index cb99627..94a60b9 100644 --- a/tests/Panel/Request/RequestHeadersRendererTest.php +++ b/tests/Panel/Request/RequestHeadersRendererTest.php @@ -91,6 +91,16 @@ public function testRenderBuildsOneSearchableInboundAndOutboundLedger(): void $html, 'Colonless SAPI response lines must remain inspectable.', ); + self::assertMatchesRegularExpression( + '~
]*>\s*
\s*Raw response line 0~', + $html, + 'Colonless lines must keep their dedicated raw-row class.', + ); + self::assertMatchesRegularExpression( + '~
]*>\s*
\s*Accept~', + $html, + 'Named fields must keep the plain diagnostic-row class.', + ); self::assertStringNotContainsString('withLive(false)), + new RequestRoutingView(CurrentRouteView::create(), RouteInventoryView::create(routes: [])), ); self::assertStringNotContainsString( @@ -345,6 +343,7 @@ public function testRenderOpensPopulatedInputAndCollapsesEmptyBuckets(): void ), ], ); + $html = RequestRenderer::render($view, self::routingView()); self::assertSame( @@ -395,12 +394,28 @@ public function testRenderPreservesSpecificMetadataInsideSearchableDetails(): vo ->withMode('BOTH') ->withType('GROUP'); - $html = self::routeLedger(RequestRenderer::render( - self::requestView(), - new RequestRoutingView(current: CurrentRouteView::create(), inventory: RouteInventoryView::create(routes: [$definition])), - )); + $html = self::routeLedger( + RequestRenderer::render( + self::requestView(), + new RequestRoutingView( + current: CurrentRouteView::create(), + inventory: RouteInventoryView::create(routes: [$definition]), + ), + ), + ); - foreach (['post/view', 'one.example.test', 'two.example.test', 'Post::<view>', 'Auth', 'Session', '.html', 'BOTH', 'GROUP'] as $value) { + foreach ( + [ + 'post/view', + 'one.example.test', + 'two.example.test', + 'Post::<view>', + 'Auth', + 'Session', + '.html', + 'BOTH', + 'GROUP', + ] as $value) { self::assertStringContainsString( $value, $html, @@ -417,7 +432,10 @@ public function testRenderPreservesSpecificMetadataInsideSearchableDetails(): vo 'data-yii-debug-filter-unit="routes"', RequestRenderer::render( self::requestView(), - new RequestRoutingView(current: CurrentRouteView::create(), inventory: RouteInventoryView::create(routes: [$definition])), + new RequestRoutingView( + current: CurrentRouteView::create(), + inventory: RouteInventoryView::create(routes: [$definition]), + ), ), 'The filter must count routes rather than metadata fields.', ); @@ -499,6 +517,7 @@ public function testRenderSessionDisclosuresFollowTheirOwnDataAndFilterScope(): RequestDataNormalizer::fromPanelData($data, null), new RequestRoutingView(current: CurrentRouteView::create()), ); + preg_match_all('~
]*>.*?
~s', $html, $matches); self::assertCount( @@ -591,7 +610,6 @@ public function testRenderSurfacesIndependentErrorsAndCollapsedResolutionTrace() ], ) ->withSource('Current application configuration.') - ->withLive(true) ->withError('Current route configuration could not be read.'), ); @@ -627,6 +645,31 @@ public function testRenderSurfacesIndependentErrorsAndCollapsedResolutionTrace() $html, 'Unknown badge variants must degrade to the safe muted vocabulary.', ); + self::assertStringContainsString( + << + + + + # + + Rule + + Parent + + Result + + + + HTML, + $html, + 'Trace columns must stay complete and ordered.', + ); + self::assertStringContainsString( + 'Matched', + $html, + 'A matched trace row must carry the success badge.', + ); } public function testRenderUsesCommonColumnsVocabularyMethodsAndAccessibleMatch(): void diff --git a/tests/Panel/Request/RequestRoutingViewModelsTest.php b/tests/Panel/Request/RequestRoutingViewModelsTest.php index 7734df5..35c9155 100644 --- a/tests/Panel/Request/RequestRoutingViewModelsTest.php +++ b/tests/Panel/Request/RequestRoutingViewModelsTest.php @@ -27,12 +27,14 @@ final class RequestRoutingViewModelsTest extends TestCase { public function testConstructorsStayCompactAndModelStateIsPrivate(): void { - foreach ([ - RequestHero::class => 2, - RouteDefinition::class => 2, - CurrentRouteView::class => 1, - RouteInventoryView::class => 1, - ] as $class => $count) { + foreach ( + [ + RequestHero::class => 2, + RouteDefinition::class => 2, + CurrentRouteView::class => 1, + RouteInventoryView::class => 1, + ] as $class => $count + ) { $reflection = new ReflectionClass($class); self::assertSame( @@ -58,7 +60,9 @@ public function testConstructorsStayCompactAndModelStateIsPrivate(): void public function testCurrentRouteDefaultsRepresentUnavailableDiagnostics(): void { $current = CurrentRouteView::create(); + $trace = new RouteTraceRow('fallback'); + $inventory = RouteInventoryView::create([]); self::assertEquals( @@ -76,16 +80,19 @@ public function testCurrentRouteDefaultsRepresentUnavailableDiagnostics(): void $trace->matched, 'A trace row must remain unmatched until an adapter reports a match.', ); - self::assertTrue( - $inventory->isLive(), - 'Route inventories must describe live configuration by default.', + self::assertEquals( + RouteInventoryView::create([]), + $inventory->withBadges([])->withSource('Current application configuration')->withError(null), + 'Inventory defaults must remain explicit and deterministic.', ); } public function testCurrentRouteFluentOptionsCanResetWithoutChangingTheOriginal(): void { $definition = RouteDefinition::create('orders', '/orders'); + $trace = new RouteTraceRow('/orders'); + $current = CurrentRouteView::create('orders') ->withAction('OrderAction') ->withParameters(['id' => 7]) @@ -93,6 +100,7 @@ public function testCurrentRouteFluentOptionsCanResetWithoutChangingTheOriginal( ->withMessage('Matched.') ->withTrace([$trace]) ->withError('Captured failure.'); + $reset = $current ->withAction(null) ->withParameters([]) @@ -100,9 +108,13 @@ public function testCurrentRouteFluentOptionsCanResetWithoutChangingTheOriginal( ->withMessage(null) ->withTrace([]) ->withError(null); + $parameters = $current->getParameters(); + $parameters['id'] = 8; + $rows = $current->getTrace(); + $rows[] = new RouteTraceRow('fallback'); self::assertSame( @@ -154,14 +166,16 @@ public function testCurrentRouteOptionsReturnIndependentCopies(): void $trace = new RouteTraceRow('/orders'); - foreach ([ - $current->withAction('OrderAction'), - $current->withParameters(['id' => 7]), - $current->withDefinition($definition), - $current->withMessage('Matched.'), - $current->withTrace([$trace]), - $current->withError('Captured failure.'), - ] as $clone) { + foreach ( + [ + $current->withAction('OrderAction'), + $current->withParameters(['id' => 7]), + $current->withDefinition($definition), + $current->withMessage('Matched.'), + $current->withTrace([$trace]), + $current->withError('Captured failure.'), + ] as $clone + ) { self::assertNotSame( $current, $clone, @@ -186,7 +200,8 @@ public function testFactoriesPreserveConstructorSemantics(): void [new RouteDefinition(), RouteDefinition::create()], [new RouteDefinition('orders', '/orders'), RouteDefinition::create(name: 'orders', pattern: '/orders')], [new RouteInventoryView([]), RouteInventoryView::create(routes: [])], - ] as [$constructed, $created]) { + ] as [$constructed, $created] + ) { self::assertEquals( $constructed, $created, @@ -211,12 +226,11 @@ public function testInventoryFluentOptionsPreserveRoutesAndAllowResets(): void $inventory = RouteInventoryView::create($routes) ->withBadges($badges) ->withSource('Captured configuration') - ->withLive(false) ->withError('Inventory failure.'); + $reset = $inventory ->withBadges([]) ->withSource('Current application configuration') - ->withLive(true) ->withError(null); $routes[] = RouteDefinition::create('other', '/other'); @@ -245,10 +259,6 @@ public function testInventoryFluentOptionsPreserveRoutesAndAllowResets(): void $inventory->getSource(), 'Later options must preserve provenance.', ); - self::assertFalse( - $inventory->isLive(), - 'A non-live inventory must preserve its explicit false value.', - ); self::assertSame( 'Inventory failure.', $inventory->getError(), @@ -270,9 +280,9 @@ public function testInventoryOptionsReturnIndependentCopies(): void [ $inventory->withBadges([new RouteBadge('Pretty URLs enabled')]), $inventory->withSource('Captured configuration'), - $inventory->withLive(false), $inventory->withError('Inventory failure.'), - ] as $clone) { + ] as $clone + ) { self::assertNotSame( $inventory, $clone, @@ -302,9 +312,7 @@ public function testRoutingViewComposesDefinitionTraceBadgesAndInventory(): void ->withDefinition($definition) ->withMessage('Matched home.') ->withTrace([$trace]); - $inventory = RouteInventoryView::create(routes: [$definition]) - ->withBadges([$badge]) - ->withLive(false); + $inventory = RouteInventoryView::create(routes: [$definition])->withBadges([$badge]); $view = new RequestRoutingView($current, $inventory); diff --git a/tests/Panel/Vite/ViteSectionRendererTest.php b/tests/Panel/Vite/ViteSectionRendererTest.php index ea6bb3e..cf47c08 100644 --- a/tests/Panel/Vite/ViteSectionRendererTest.php +++ b/tests/Panel/Vite/ViteSectionRendererTest.php @@ -33,9 +33,21 @@ public function testDevelopmentIntegrationUsesSharedPanelTableWithoutAVisibleCom $html, 'Configuration must use the same overview table as the other diagnostic panels.', ); - self::assertStringContainsString('scope="row"', $html, 'Configuration labels must identify their table rows.'); - self::assertStringContainsString('Component ID', $html, 'The exact adapter registration ID must remain visible.'); - self::assertStringContainsString('inertiaVue', $html, 'The exact case-sensitive component ID must be preserved.'); + self::assertStringContainsString( + 'scope="row"', + $html, + 'Configuration labels must identify their table rows.', + ); + self::assertStringContainsString( + 'Component ID', + $html, + 'The exact adapter registration ID must remain visible.', + ); + self::assertStringContainsString( + 'inertiaVue', + $html, + 'The exact case-sensitive component ID must be preserved.', + ); self::assertDoesNotMatchRegularExpression( '~]*>\s*inertiaVue\s*~', $html, @@ -46,7 +58,11 @@ public function testDevelopmentIntegrationUsesSharedPanelTableWithoutAVisibleCom $html, 'The chunk heading must use the shared tabular-section treatment.', ); - self::assertStringContainsString('Implementation', $html, 'The implementation field must remain readable.'); + self::assertStringContainsString( + 'Implementation', + $html, + 'The implementation field must remain readable.', + ); self::assertMatchesRegularExpression( '~\s*Mode\s*\s*Development\s*~', $html, @@ -58,10 +74,26 @@ public function testDevelopmentIntegrationUsesSharedPanelTableWithoutAVisibleCom $html, 'Successful inspection must use the success badge in its overview row.', ); - self::assertStringContainsString('resources/js/app.js', $html, 'Configured entrypoints must be rendered.'); - self::assertStringContainsString('http://localhost:5173', $html, 'The development server must be rendered.'); - self::assertStringContainsString('Enabled', $html, 'Enabled development options must remain explicit.'); - self::assertStringContainsString('Not applicable', $html, 'Production-only options must be identified.'); + self::assertStringContainsString( + 'resources/js/app.js', + $html, + 'Configured entrypoints must be rendered.', + ); + self::assertStringContainsString( + 'http://localhost:5173', + $html, + 'The development server must be rendered.', + ); + self::assertStringContainsString( + 'Enabled', + $html, + 'Enabled development options must remain explicit.', + ); + self::assertStringContainsString( + 'Not applicable', + $html, + 'Production-only options must be identified.', + ); self::assertStringContainsString( 'Development mode resolves entry points through the dev server.', $html, @@ -80,8 +112,16 @@ public function testDifferentComponentModesRenderMixedPluralSummary(): void ), ); - self::assertStringContainsString('2 components', $html, 'Multiple integrations need a plural count.'); - self::assertStringContainsString('Mixed', $html, 'Different runtime modes must be summarized as mixed.'); + self::assertStringContainsString( + '2 components', + $html, + 'Multiple integrations need a plural count.', + ); + self::assertStringContainsString( + 'Mixed', + $html, + 'Different runtime modes must be summarized as mixed.', + ); self::assertSame( 2, substr_count($html, 'class="yii-debug-vite-component"'), @@ -97,7 +137,11 @@ public function testEmptySummaryRendersAccessibleHeadingAndFallback(): void $html, 'The panel name must remain available to assistive technology.', ); - self::assertStringContainsString('0 components', $html, 'The empty summary must report zero components.'); + self::assertStringContainsString( + '0 components', + $html, + 'The empty summary must report zero components.', + ); self::assertStringContainsString( 'No Vite integrations captured', $html, @@ -138,10 +182,26 @@ public function testOverviewEscapesPlainValuesWhileRenderingTypedBadgeMarkup(): ), ); - self::assertStringContainsString('<vite>', $html, 'Plain component metadata must remain escaped.'); - self::assertStringContainsString('<script>', $html, 'Plain entrypoint metadata must remain escaped.'); - self::assertStringNotContainsString('', $html, 'Component metadata must never become markup.'); - self::assertStringNotContainsString('