From abb8bb253b72cb0a9ce878d274e681f61dbec42e Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 5 Sep 2026 14:52:26 -0400 Subject: [PATCH] feat(api): add framework-neutral `PanelComparison` for ordered panel IDs, failure precedence, capture states, and combined structural/state counts without changing adapter output or diagnostic values. --- CHANGELOG.md | 1 + README.md | 27 ++- src/Comparison/PanelComparison.php | 129 +++++++++++++ tests/Comparison/PanelComparisonTest.php | 74 ++++++++ tests/Provider/PanelComparisonProvider.php | 208 +++++++++++++++++++++ 5 files changed, 435 insertions(+), 4 deletions(-) create mode 100644 src/Comparison/PanelComparison.php create mode 100644 tests/Comparison/PanelComparisonTest.php create mode 100644 tests/Provider/PanelComparisonProvider.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 51967d8..801f550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,3 +39,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - refactor(ui): unify Request, Server, Session, Input, routing, and tabs with collapsible filters, focus states, and refreshed assets. - feat(api): centralize typed structural payload differences and add immutable fluent toolbar item and panel construction while preserving diagnostic values, constructors, and serialized payloads. - feat(api): centralize request-summary metric calculations and formatting in `SummaryMetricComparison`, preserving history labels, order, units, rounding, percentages, trends, and panel links. +- feat(api): add framework-neutral `PanelComparison` for ordered panel IDs, failure precedence, capture states, and combined structural/state counts without changing adapter output or diagnostic values. diff --git a/README.md b/README.md index 0f7116f..9eb3dfa 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,26 @@ An empty array is a captured leaf, not absence. Nested `null`, `false`, integer distinct. Leaf paths escape `~` and `/`; list positions matter, while map insertion order does not affect the counts. The comparison fingerprints typed leaves temporarily and retains only counts in its result. It does not alter or redact -the source payloads. Adapters retain responsibility for capture/failure precedence, state-only changes, panel ordering, -and labels. See the [architecture review](docs/architecture-review.md) for boundaries and follow-up work. +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 + +`PHPForge\Debug\Comparison\PanelComparison::between($baseline, $target, $panelLabels)` accepts two `DebugSnapshot` +instances and an optional map of labels in display order. It returns an ordered list of immutable results exposing +`id`, `label`, `baselineState`, `targetState`, `added`, `removed`, `changed`, and `unchanged`. + +Only IDs observed in either snapshot's payloads or failures are included, once each. Observed IDs with configured labels +come first in configuration order; remaining IDs use PHP's existing regular ascending sort and their ID as the label. +Unknown configured IDs do not create rows, and an explicitly empty label remains empty. + +Failure envelopes take precedence even when a payload exists for the same ID. States remain `Failed`, `Captured`, and +`Not captured`; captured empty arrays are not absence. Structural counts come from `PayloadDifference` unchanged. +If states differ but `added + removed + changed` is zero, `changed` becomes one; `unchanged` is preserved. A transition +with structural differences does not add another change. The result retains no diagnostic values and applies no redaction. + +Yii2 and Yii3 map these results into their existing public `HistoryPanelComparison` models. Yii3 retains +`HistoryPanelStates` and `HistoryPanelDifferenceCounts`; neither adapter exposes Core results in place of its public models. ## Request-summary metric comparison @@ -156,8 +174,9 @@ floating-point results, not rounded display values or an epsilon: a displayed ze ### Coordinated publication -Publish the Core revision containing `SummaryMetricComparison` before either adapter revision that consumes it. +Publish the Core revision containing `SummaryMetricComparison` and `PanelComparison` before either adapter revision +that consumes it. Both adapters currently require `php-forge/debug-core` at `^0.1@dev`; this constraint alone does not ensure that an -installed or locked development revision includes the new class. Update and verify consuming application locks together. +installed or locked development revision includes these classes. Update and verify consuming application locks together. Local adapter installations linked to this workspace verify integration but do not validate older published artifacts. No adapter constructor, property, getter, return type, template, asset, or persisted representation changes. diff --git a/src/Comparison/PanelComparison.php b/src/Comparison/PanelComparison.php new file mode 100644 index 0000000..7d7209b --- /dev/null +++ b/src/Comparison/PanelComparison.php @@ -0,0 +1,129 @@ + $panelLabels Display names indexed by stable panel ID, in display order. + * + * @return list + */ + public static function between(DebugSnapshot $baseline, DebugSnapshot $target, array $panelLabels = []): array + { + $observedIds = array_unique( + [ + ...array_keys($baseline->panels), + ...array_keys($baseline->failures), + ...array_keys($target->panels), + ...array_keys($target->failures), + ], + ); + + $orderedIds = []; + + foreach ($panelLabels as $id => $_label) { + if (in_array($id, $observedIds, true)) { + $orderedIds[] = $id; + } + } + + $extraIds = array_diff($observedIds, $orderedIds); + + sort($extraIds); + + $orderedIds = [ + ...$orderedIds, + ...$extraIds, + ]; + + $comparisons = []; + + foreach ($orderedIds as $id) { + $baselineState = self::panelState($baseline, $id); + $targetState = self::panelState($target, $id); + + $difference = PayloadDifference::between( + self::panelPayload($baseline, $id), + self::panelPayload($target, $id), + ); + + $added = $difference->added; + $removed = $difference->removed; + $changed = $difference->changed; + $unchanged = $difference->unchanged; + + if ($added + $removed + $changed === 0 && $baselineState !== $targetState) { + $changed = 1; + } + + $comparisons[] = new self( + id: $id, + label: $panelLabels[$id] ?? $id, + baselineState: $baselineState, + targetState: $targetState, + added: $added, + removed: $removed, + changed: $changed, + unchanged: $unchanged, + ); + } + + return $comparisons; + } + + /** + * Returns the captured payload or failure envelope, preserving the distinction between absent and empty. + * + * @return array|null + */ + private static function panelPayload(DebugSnapshot $snapshot, string $id): array|null + { + if (isset($snapshot->failures[$id])) { + return ['failure' => $snapshot->failures[$id]->jsonSerialize()]; + } + + return $snapshot->panels[$id] ?? null; + } + + private static function panelState(DebugSnapshot $snapshot, string $id): string + { + if (array_key_exists($id, $snapshot->failures)) { + return 'Failed'; + } + + return array_key_exists($id, $snapshot->panels) ? 'Captured' : 'Not captured'; + } +} diff --git a/tests/Comparison/PanelComparisonTest.php b/tests/Comparison/PanelComparisonTest.php new file mode 100644 index 0000000..348bda8 --- /dev/null +++ b/tests/Comparison/PanelComparisonTest.php @@ -0,0 +1,74 @@ + $labels + * @param list $expected + */ + #[DataProviderExternal(PanelComparisonProvider::class, 'comparisons')] + public function testBetweenPreservesPanelContracts( + DebugSnapshot $baseline, + DebugSnapshot $target, + array $labels, + array $expected, + bool $hasDifferences, + ): void { + $beforeBaseline = $baseline->jsonSerialize(); + $beforeTarget = $target->jsonSerialize(); + + $panels = PanelComparison::between($baseline, $target, $labels); + + $actual = []; + $differenceCount = 0; + + foreach ($panels as $panel) { + $differenceCount += $panel->added + $panel->removed + $panel->changed; + $actual[] = [ + $panel->id, + $panel->label, + $panel->baselineState, + $panel->targetState, + $panel->added, + $panel->removed, + $panel->changed, + $panel->unchanged, + ]; + } + + self::assertSame( + $expected, + $actual, + 'Panel IDs, labels, order, states, and counts must remain exact.', + ); + self::assertSame( + $hasDifferences, + $differenceCount > 0, + 'Difference detection must remain exact.' + ); + self::assertSame( + $beforeBaseline, + $baseline->jsonSerialize(), + 'Baseline diagnostics must remain intact.', + ); + self::assertSame( + $beforeTarget, + $target->jsonSerialize(), + 'Target diagnostics must remain intact.' + ); + } +} diff --git a/tests/Provider/PanelComparisonProvider.php b/tests/Provider/PanelComparisonProvider.php new file mode 100644 index 0000000..78e8181 --- /dev/null +++ b/tests/Provider/PanelComparisonProvider.php @@ -0,0 +1,208 @@ +, + * list, bool, + * }> + */ + public static function comparisons(): iterable + { + $summary = RequestSummary::create('comparison'); + + $failure = new PanelFailure( + PanelFailure::CAPTURE, + new ExceptionSnapshot( + 'RuntimeException', + 'original diagnostic', + 7, + '/app/example.php', + 42, + [], + 'original', + null, + ), + ); + + $envelope = ['failure' => $failure->jsonSerialize()]; + + yield 'unobserved labels do not create panels' => [ + new DebugSnapshot($summary, [], []), + new DebugSnapshot($summary, [], []), + ['unknown' => 'Unknown'], + [], + false, + ]; + + $states = [ + 'absent' => [[], [], 'Not captured'], + 'empty' => [['p' => []], [], 'Captured'], + 'failure' => [[], ['p' => $failure], 'Failed'], + 'envelope' => [['p' => $envelope], [], 'Captured'], + 'both' => [['p' => ['ignored' => 'baseline payload']], ['p' => $failure], 'Failed'], + ]; + $counts = [ + 'absent' => [ + 'absent' => [0, 0, 0, 0], 'empty' => [1, 0, 0, 0], 'failure' => [9, 0, 0, 0], + 'envelope' => [9, 0, 0, 0], 'both' => [9, 0, 0, 0], + ], + 'empty' => [ + 'absent' => [0, 1, 0, 0], 'empty' => [0, 0, 0, 1], 'failure' => [9, 1, 0, 0], + 'envelope' => [9, 1, 0, 0], 'both' => [9, 1, 0, 0], + ], + 'failure' => [ + 'absent' => [0, 9, 0, 0], 'empty' => [1, 9, 0, 0], 'failure' => [0, 0, 0, 9], + 'envelope' => [0, 0, 1, 9], 'both' => [0, 0, 0, 9], + ], + 'envelope' => [ + 'absent' => [0, 9, 0, 0], 'empty' => [1, 9, 0, 0], 'failure' => [0, 0, 1, 9], + 'envelope' => [0, 0, 0, 9], 'both' => [0, 0, 1, 9], + ], + 'both' => [ + 'absent' => [0, 9, 0, 0], 'empty' => [1, 9, 0, 0], 'failure' => [0, 0, 0, 9], + 'envelope' => [0, 0, 1, 9], 'both' => [0, 0, 0, 9], + ], + ]; + + foreach ($states as $baselineName => [$baselinePanels, $baselineFailures, $baselineState]) { + foreach ($states as $targetName => [$targetPanels, $targetFailures, $targetState]) { + [$added, $removed, $changed, $unchanged] = $counts[$baselineName][$targetName]; + + yield "{$baselineName} to {$targetName}" => [ + new DebugSnapshot($summary, $baselinePanels, $baselineFailures), + new DebugSnapshot($summary, $targetPanels, $targetFailures), + ['p' => 'Panel'], + $baselineName === 'absent' && $targetName === 'absent' + ? [] + : [['p', 'Panel', $baselineState, $targetState, $added, $removed, $changed, $unchanged]], + $added + $removed + $changed > 0, + ]; + } + } + + yield 'configured observed IDs first and extras use regular sorting' => [ + new DebugSnapshot($summary, ['z' => [], 'a10' => [], 'request' => [], 'a2' => []], ['z' => $failure]), + new DebugSnapshot($summary, ['z' => [], 'a10' => [], 'request' => [], 'a2' => []], ['z' => $failure]), + ['unknown' => 'Unknown', 'request' => '', 'z' => 'Last configured'], + [ + ['request', '', 'Captured', 'Captured', 0, 0, 0, 1], + ['z', 'Last configured', 'Failed', 'Failed', 0, 0, 0, 9], + ['a10', 'a10', 'Captured', 'Captured', 0, 0, 0, 1], + ['a2', 'a2', 'Captured', 'Captured', 0, 0, 0, 1], + ], + false, + ]; + + yield 'all four maps contribute unique IDs' => [ + new DebugSnapshot($summary, ['d' => []], ['c' => $failure]), + new DebugSnapshot($summary, ['b' => []], ['a' => $failure]), + [], + [ + ['a', 'a', 'Not captured', 'Failed', 9, 0, 0, 0], + ['b', 'b', 'Not captured', 'Captured', 1, 0, 0, 0], + ['c', 'c', 'Failed', 'Not captured', 0, 9, 0, 0], + ['d', 'd', 'Captured', 'Not captured', 0, 1, 0, 0], + ], + true, + ]; + + yield 'combined structural counters preserve original diagnostic types' => [ + new DebugSnapshot($summary, ['p' => ['same' => 1, 'changed' => 'secret one', 'removed' => 2]], []), + new DebugSnapshot($summary, ['p' => ['same' => 1, 'changed' => 'secret two', 'a' => 3, 'b' => 4]], []), + [], + [['p', 'p', 'Captured', 'Captured', 2, 1, 1, 1]], + true, + ]; + + $changedEnvelope = $failure->jsonSerialize(); + + $changedEnvelope['extra'] = 'added leaf'; + + yield 'state transition with only removed leaves' => [ + new DebugSnapshot($summary, ['p' => ['failure' => $changedEnvelope]], []), + new DebugSnapshot($summary, [], ['p' => $failure]), + [], + [['p', 'p', 'Captured', 'Failed', 0, 1, 0, 9]], + true, + ]; + + yield 'state transition with only added leaves' => [ + new DebugSnapshot($summary, [], ['p' => $failure]), + new DebugSnapshot($summary, ['p' => ['failure' => $changedEnvelope]], []), + [], + [['p', 'p', 'Failed', 'Captured', 1, 0, 0, 9]], + true, + ]; + + $changedEnvelope['stage'] = 'hydrate'; + + unset($changedEnvelope['exception']); + + yield 'state transition with combined structural differences' => [ + new DebugSnapshot($summary, ['p' => ['failure' => $changedEnvelope]], []), + new DebugSnapshot($summary, [], ['p' => $failure]), + [], + [['p', 'p', 'Captured', 'Failed', 8, 1, 1, 0]], + true, + ]; + + $renamedEnvelope = $failure->jsonSerialize(); + + $renamedEnvelope['stagex'] = PanelFailure::CAPTURE; + + unset($renamedEnvelope['stage']); + + yield 'equal additions and removals do not cancel a state transition' => [ + new DebugSnapshot($summary, ['p' => ['failure' => $renamedEnvelope]], []), + new DebugSnapshot($summary, [], ['p' => $failure]), + [], + [['p', 'p', 'Captured', 'Failed', 1, 1, 0, 8]], + true, + ]; + + $renamedEnvelope['exception'] = ( + new ExceptionSnapshot( + 'RuntimeException', + 'different message', + 99, + '/app/example.php', + 42, + [], + 'original', + null, + ) + )->jsonSerialize(); + + yield 'changed leaves matching additions and removals are not replaced' => [ + new DebugSnapshot($summary, ['p' => ['failure' => $renamedEnvelope]], []), + new DebugSnapshot($summary, [], ['p' => $failure]), + [], + [['p', 'p', 'Captured', 'Failed', 1, 1, 2, 6]], + true, + ]; + + $changedFailure = new PanelFailure( + PanelFailure::HYDRATE, + new ExceptionSnapshot('RuntimeException', 'different diagnostic', 7, '/app/example.php', 42, [], 'original', null), + ); + + yield 'failure changes are not hidden by identical coexisting payloads' => [ + new DebugSnapshot($summary, ['p' => []], ['p' => $failure]), + new DebugSnapshot($summary, ['p' => []], ['p' => $changedFailure]), + [], + [['p', 'p', 'Failed', 'Failed', 0, 0, 2, 7]], + true, + ]; + } +}