Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
129 changes: 129 additions & 0 deletions src/Comparison/PanelComparison.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Comparison;

use PHPForge\Debug\Storage\DebugSnapshot;

use function array_diff;
use function array_key_exists;
use function array_keys;
use function array_unique;
use function in_array;
use function sort;

/**
* Compares observed panels in configured order, combining structural differences with capture-state transitions.
*
* Results retain only identity, labels, states, and counts; source diagnostics are neither copied nor redacted.
*/
final readonly class PanelComparison
{
private function __construct(
public string $id,
public string $label,
public string $baselineState,
public string $targetState,
public int $added,
public int $removed,
public int $changed,
public int $unchanged,
) {}

/**
* Compares the union of payload and failure IDs, with observed configured IDs first and sorted extras last.
*
* Failures take precedence over payloads. A state transition adds one change only when there are no structural
* additions, removals, or changes; unchanged leaves remain counted even for that state-only transition.
*
* @param array<string, string> $panelLabels Display names indexed by stable panel ID, in display order.
*
* @return list<self>
*/
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<string, mixed>|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';
}
}
74 changes: 74 additions & 0 deletions tests/Comparison/PanelComparisonTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Tests\Comparison;

use PHPForge\Debug\Comparison\PanelComparison;
use PHPForge\Debug\Storage\DebugSnapshot;
use PHPForge\Debug\Tests\Provider\PanelComparisonProvider;
use PHPUnit\Framework\Attributes\{DataProviderExternal, Group};
use PHPUnit\Framework\TestCase;

/**
* Locks the original adapter output before and after sharing panel comparison.
*/
#[Group('history')]
final class PanelComparisonTest extends TestCase
{
/**
* @param array<string, string> $labels
* @param list<array{string, string, string, string, int, int, int, int}> $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.'
);
}
}
Loading
Loading