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 @@ -38,3 +38,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- feat(ui): expose captured trace totals and style clickable Log severity counters as filter pills alongside History status shortcuts.
- 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.
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,30 @@ 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. Adapters retain responsibility for capture/failure precedence, state-only changes, panel ordering,
labels, and metric presentation. See the [architecture review](docs/architecture-review.md) for boundaries and follow-up work.
and labels. See the [architecture review](docs/architecture-review.md) for boundaries and follow-up work.

## Request-summary metric comparison

`PHPForge\Debug\Comparison\SummaryMetricComparison::between($baseline, $target)` accepts two `RequestSummary`
instances and returns an ordered list of immutable comparisons. Each result exposes `label`, `baseline`, `target`,
`delta`, `trend`, and nullable `panelId`. Adapters map these fields into their own public models; no framework dependency,
capture policy, payload comparison, or snapshot mutation is involved.

The canonical order is Status, Method, AJAX, Duration, Peak memory, SQL queries, Mail messages, and Excessive DB callers.
Duration uses milliseconds and memory uses bytes divided by 1,048,576 with the existing `MB` label. Both use two decimal
places; counters use none. Decimal points, comma grouping, signs, one-decimal percentages, and related panel IDs remain
identical to the original adapters.

Missing profiling values remain `Not captured`; one missing side produces `Not comparable` with a neutral trend.
Two missing values produce `No change`. Status zero means `Not captured`, AJAX `false` means `No`, and an empty method
remains an empty string. Captured numeric zero is never treated as missing, and zero baselines omit percentages.
Deltas subtract the scaled values before formatting, while percentages use the original values. Comparisons use exact
floating-point results, not rounded display values or an epsilon: a displayed zero delta may still have a direction.

### Coordinated publication

Publish the Core revision containing `SummaryMetricComparison` 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.
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.
195 changes: 195 additions & 0 deletions src/Comparison/SummaryMetricComparison.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Comparison;

use PHPForge\Debug\Storage\RequestSummary;

use function number_format;

/**
* Calculates and formats request-summary metrics without depending on adapter presentation models.
*
* Results retain the history labels, order, units, trends, and related panel IDs used by both adapters.
*/
final readonly class SummaryMetricComparison
{
private function __construct(
public string $label,
public string $baseline,
public string $target,
public string $delta,
public string $trend,
public string|null $panelId = null,
) {}

/**
* Compares summaries in the canonical history metric order.
*
* @return list<self>
*/
public static function between(RequestSummary $baseline, RequestSummary $target): array
{
return [
self::textMetric(
'Status',
self::status($baseline->statusCode),
self::status($target->statusCode),
),
self::textMetric(
'Method',
$baseline->method,
$target->method,
),
self::textMetric(
'AJAX',
self::yesNo($baseline->ajax),
self::yesNo($target->ajax),
),
self::nullableFloatMetric(
'Duration',
$baseline->processingTime,
$target->processingTime,
1000,
'ms',
'profiling',
),
self::nullableFloatMetric(
'Peak memory',
$baseline->peakMemory,
$target->peakMemory,
1 / 1_048_576,
'MB',
'profiling',
),
self::integerMetric(
'SQL queries',
$baseline->sqlCount,
$target->sqlCount,
'db',
),
self::integerMetric(
'Mail messages',
$baseline->mailCount,
$target->mailCount,
'mail',
),
self::integerMetric(
'Excessive DB callers',
$baseline->excessiveCallersCount,
$target->excessiveCallersCount,
'db',
),
];
}

private static function formatNumber(float|int $value, string $unit, int $precision): string
{
$formatted = number_format($value, $precision, '.', ',');

return $unit === '' ? $formatted : "{$formatted} {$unit}";
}

private static function integerMetric(
string $label,
int $baseline,
int $target,
string|null $panelId = null,
): self {
return self::numericMetric(
$label,
$baseline,
$target,
1,
'',
$panelId,
0,
);
}

private static function nullableFloatMetric(
string $label,
float|int|null $baseline,
float|int|null $target,
float $scale,
string $unit,
string|null $panelId = null,
): self {
if ($baseline === null || $target === null) {
return new self(
label: $label,
baseline: $baseline === null ? 'Not captured' : self::formatNumber($baseline * $scale, $unit, 2),
target: $target === null ? 'Not captured' : self::formatNumber($target * $scale, $unit, 2),
delta: $baseline === $target ? 'No change' : 'Not comparable',
trend: 'neutral',
panelId: $panelId,
);
}

return self::numericMetric(
$label,
$baseline,
$target,
$scale,
$unit,
$panelId,
2,
);
}

private static function numericMetric(
string $label,
float|int $baseline,
float|int $target,
float $scale,
string $unit,
string|null $panelId,
int $precision,
): self {
$scaledBaseline = $baseline * $scale;
$scaledTarget = $target * $scale;
$scaledDelta = $scaledTarget - $scaledBaseline;
$trend = $scaledDelta > 0 ? 'up' : ($scaledDelta < 0 ? 'down' : 'neutral');

$delta = 'No change';

if ($scaledDelta !== 0.0) {
$sign = $trend === 'up' ? '+' : '';
$percentage = (float) $baseline !== 0.0
? " ({$sign}" . number_format((($target - $baseline) / $baseline) * 100, 1) . '%)'
: '';
$delta = $sign . self::formatNumber($scaledDelta, $unit, $precision) . $percentage;
}

return new self(
label: $label,
baseline: self::formatNumber($scaledBaseline, $unit, $precision),
target: self::formatNumber($scaledTarget, $unit, $precision),
delta: $delta,
trend: $trend,
panelId: $panelId,
);
}

private static function status(int $statusCode): string
{
return $statusCode === 0 ? 'Not captured' : (string) $statusCode;
}

private static function textMetric(string $label, string $baseline, string $target): self
{
return new self(
label: $label,
baseline: $baseline,
target: $target,
delta: $baseline === $target ? 'No change' : 'Changed',
trend: 'neutral',
);
}

private static function yesNo(bool $value): string
{
return $value ? 'Yes' : 'No';
}
}
92 changes: 92 additions & 0 deletions tests/Comparison/SummaryMetricComparisonTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Tests\Comparison;

use PHPForge\Debug\Comparison\SummaryMetricComparison;
use PHPForge\Debug\Storage\RequestSummary;
use PHPForge\Debug\Tests\Provider\SummaryMetricComparisonProvider;
use PHPUnit\Framework\Attributes\{DataProviderExternal, Group};
use PHPUnit\Framework\TestCase;

/**
* Tests summary metric compatibility with the original adapter calculations and formatting.
*/
#[Group('history')]
final class SummaryMetricComparisonTest extends TestCase
{
/**
* @param list<array{string, string, string, string, string, string|null}> $expected
*/
#[DataProviderExternal(SummaryMetricComparisonProvider::class, 'summaries')]
public function testBetweenPreservesAllMetricContracts(
RequestSummary $baseline,
RequestSummary $target,
array $expected,
): void {
$beforeBaseline = $baseline->jsonSerialize();
$beforeTarget = $target->jsonSerialize();
$actual = [];

foreach (SummaryMetricComparison::between($baseline, $target) as $metric) {
$actual[] = self::row($metric);
}

self::assertSame(
$expected,
$actual,
'Labels, order, values, deltas, trends, and panel IDs must remain exact.',
);
self::assertSame(
$beforeBaseline,
$baseline->jsonSerialize(),
'The baseline must remain unchanged.'
);
self::assertSame(
$beforeTarget,
$target->jsonSerialize(),
'The target must remain unchanged.'
);
}

/**
* @param array{string, string, string, string, string, string|null} $expected
*/
#[DataProviderExternal(SummaryMetricComparisonProvider::class, 'metrics')]
public function testBetweenPreservesMetricBoundaries(
RequestSummary $baseline,
RequestSummary $target,
int $index,
array $expected,
): void {
$metrics = SummaryMetricComparison::between($baseline, $target);

if (!isset($metrics[$index])) {
self::fail(
'The metric must retain its canonical position.',
);
}

self::assertSame(
$expected,
self::row($metrics[$index]),
'Metric arithmetic and formatting must remain exact.'
);
}

/**
* @return array{string, string, string, string, string, string|null}
*/
private static function row(SummaryMetricComparison $metric): array
{
return [
$metric->label,
$metric->baseline,
$metric->target,
$metric->delta,
$metric->trend,
$metric->panelId,
];
}
}
Loading
Loading