From b752cc034ab9ec0a0f38bf6d7f49aa6f9ca657ed Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 5 Sep 2026 08:46:39 -0400 Subject: [PATCH] feat(api): centralize typed structural payload differences and add immutable fluent toolbar item and panel construction while preserving diagnostic values, constructors, and serialized payloads. --- CHANGELOG.md | 1 + README.md | 36 +++ src/Comparison/PayloadDifference.php | 98 +++++++ src/Toolbar/ToolbarItem.php | 114 +++++++- src/Toolbar/ToolbarPanel.php | 52 ++++ tests/Comparison/PayloadDifferenceTest.php | 184 ++++++++++++ tests/Provider/ToolbarItemProvider.php | 34 +++ tests/Toolbar/ToolbarItemTest.php | 307 +++++++++++++++++++++ tests/Toolbar/ToolbarPanelTest.php | 140 ++++++++++ 9 files changed, 964 insertions(+), 2 deletions(-) create mode 100644 src/Comparison/PayloadDifference.php create mode 100644 tests/Comparison/PayloadDifferenceTest.php create mode 100644 tests/Provider/ToolbarItemProvider.php create mode 100644 tests/Toolbar/ToolbarItemTest.php create mode 100644 tests/Toolbar/ToolbarPanelTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ce0b36a..c3002b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,3 +37,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - test: migrate JavaScript unit and mutation tests to Vitest with isolated workers, V8 coverage, per-test mutation analysis, and supported Node.js release ranges. - 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. diff --git a/README.md b/README.md index 10109d5..81090aa 100644 --- a/README.md +++ b/README.md @@ -99,3 +99,39 @@ supports `ArrowUp`, `ArrowDown`, `Home`, and `End` on its resize separator. ## License 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. + +```php +use PHPForge\Debug\Toolbar\{ToolbarItem, ToolbarPanel}; + +$item = ToolbarItem::create('200') + ->withId('status') + ->withLabel('Status') + ->withStatus('success') + ->withTitle('Status code: 200 OK'); +$panel = ToolbarPanel::create('request', 'Request') + ->withIcon('request') + ->withUrl('/debug/view?tag=request-1&panel=request') + ->withItems([$item]); +``` + +Items offer `withId()`, `withLabel()`, `withIcon()`, `withStatus()`, `withTitle()`, and `withUrl()`. +Panels offer `withIcon()`, `withUrl()`, and `withItems()`. Every method returns a new instance and preserves all other +fields. Nullable options accept `null` to remove the field from JSON; `''` and `'0'` remain present. `withItems()` replaces +rather than appends metrics, preserves their order, and accepts `[]` to clear them. The default item status remains +`default`; the default panel metric list remains empty. Serialization and escaping responsibilities are unchanged. + +## Structural payload comparison + +`PHPForge\Debug\Comparison\PayloadDifference::between($baseline, $target)` returns an immutable result with four integer +properties: `added`, `removed`, `changed`, and `unchanged`. Arguments are captured payload arrays, or `null` for absence. +An empty array is a captured leaf, not absence. Nested `null`, `false`, integer zero, float zero, and string zero remain +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, +labels, and metric presentation. See the [architecture review](docs/architecture-review.md) for boundaries and follow-up work. diff --git a/src/Comparison/PayloadDifference.php b/src/Comparison/PayloadDifference.php new file mode 100644 index 0000000..0c0d071 --- /dev/null +++ b/src/Comparison/PayloadDifference.php @@ -0,0 +1,98 @@ +|null $baseline Baseline payload, or `null` when not captured. + * @param array|null $target Target payload, or `null` when not captured. + */ + public static function between(array|null $baseline, array|null $target): self + { + $baselineLeaves = self::flatten($baseline); + $targetLeaves = self::flatten($target); + + $changed = 0; + $unchanged = 0; + + foreach ($baselineLeaves as $path => $baselineValue) { + if (!array_key_exists($path, $targetLeaves)) { + continue; + } + + if ($baselineValue === $targetLeaves[$path]) { + ++$unchanged; + } else { + ++$changed; + } + } + + return new self( + count(array_diff_key($targetLeaves, $baselineLeaves)), + count(array_diff_key($baselineLeaves, $targetLeaves)), + $changed, + $unchanged, + ); + } + + /** + * @param array|null $payload + * + * @return array + */ + private static function flatten(array|null $payload): array + { + $leaves = []; + + if ($payload !== null) { + self::flattenValue($payload, '$', $leaves); + } + + return $leaves; + } + + /** + * @param array $leaves + */ + private static function flattenValue(mixed $value, string $path, array &$leaves): void + { + if (is_array($value)) { + if ($value === []) { + $leaves[$path] = 'array:[]'; + } + + foreach ($value as $key => $child) { + $segment = str_replace(['~', '/'], ['~0', '~1'], (string) $key); + + self::flattenValue($child, "{$path}/{$segment}", $leaves); + } + + return; + } + + $leaves[$path] = hash('sha256', serialize($value)); + } +} diff --git a/src/Toolbar/ToolbarItem.php b/src/Toolbar/ToolbarItem.php index 11b024c..71b2a30 100644 --- a/src/Toolbar/ToolbarItem.php +++ b/src/Toolbar/ToolbarItem.php @@ -32,11 +32,25 @@ public function __construct( public string|null $id = null, ) {} + /** + * Creates a metric with default presentation options. + */ + public static function create(string $value): self + { + return new self($value); + } + /** * Returns the metric payload consumed by the toolbar runtime. * - * @return array{value: string, status: string, label?: string, icon?: string, title?: string, url?: string, - * id?: string} Serialized metric payload. + * @return array{ + * value: string, + * status: string, + * label?: string, + * icon?: string, + * title?: string, url?: string, + * id?: string + * } Serialized metric payload. */ public function jsonSerialize(): array { @@ -53,4 +67,100 @@ public function jsonSerialize(): array static fn(string|null $value): bool => $value !== null, ); } + + /** + * Returns a copy with the specified icon. + */ + public function withIcon(string|null $icon): self + { + return new self( + value: $this->value, + label: $this->label, + icon: $icon, + status: $this->status, + title: $this->title, + url: $this->url, + id: $this->id, + ); + } + + /** + * Returns a copy with the specified metric ID. + */ + public function withId(string|null $id): self + { + return new self( + value: $this->value, + label: $this->label, + icon: $this->icon, + status: $this->status, + title: $this->title, + url: $this->url, + id: $id, + ); + } + + /** + * Returns a copy with the specified label. + */ + public function withLabel(string|null $label): self + { + return new self( + value: $this->value, + label: $label, + icon: $this->icon, + status: $this->status, + title: $this->title, + url: $this->url, + id: $this->id, + ); + } + + /** + * Returns a copy with the specified status. + */ + public function withStatus(string $status): self + { + return new self( + value: $this->value, + label: $this->label, + icon: $this->icon, + status: $status, + title: $this->title, + url: $this->url, + id: $this->id, + ); + } + + /** + * Returns a copy with the specified title. + */ + public function withTitle(string|null $title): self + { + return new self( + value: $this->value, + label: $this->label, + icon: $this->icon, + status: $this->status, + title: $title, + url: $this->url, + id: $this->id, + ); + } + + /** + * Returns a copy with the specified URL. + */ + public function withUrl(string|null $url): self + { + return new self( + value: $this->value, + label: $this->label, + icon: $this->icon, + status: $this->status, + title: $this->title, + url: $url, + id: $this->id, + ); + } } diff --git a/src/Toolbar/ToolbarPanel.php b/src/Toolbar/ToolbarPanel.php index 1e3bb43..3968d76 100644 --- a/src/Toolbar/ToolbarPanel.php +++ b/src/Toolbar/ToolbarPanel.php @@ -29,6 +29,14 @@ public function __construct( public array $items = [], ) {} + /** + * Creates a panel with no metrics or optional navigation. + */ + public static function create(string $id, string $title): self + { + return new self($id, $title); + } + /** * Returns the panel payload consumed by the toolbar runtime. * @@ -57,4 +65,48 @@ public function jsonSerialize(): array static fn(mixed $value): bool => $value !== null, ); } + + /** + * Returns a copy with the specified icon. + */ + public function withIcon(string|null $icon): self + { + return new self( + id: $this->id, + title: $this->title, + url: $this->url, + icon: $icon, + items: $this->items, + ); + } + + /** + * Returns a copy with the replacement metric list. + * + * @param list $items Panel metrics in display order; `[]` removes all metrics. + */ + public function withItems(array $items): self + { + return new self( + id: $this->id, + title: $this->title, + url: $this->url, + icon: $this->icon, + items: $items, + ); + } + + /** + * Returns a copy with the specified URL. + */ + public function withUrl(string|null $url): self + { + return new self( + id: $this->id, + title: $this->title, + url: $url, + icon: $this->icon, + items: $this->items, + ); + } } diff --git a/tests/Comparison/PayloadDifferenceTest.php b/tests/Comparison/PayloadDifferenceTest.php new file mode 100644 index 0000000..9656b0d --- /dev/null +++ b/tests/Comparison/PayloadDifferenceTest.php @@ -0,0 +1,184 @@ +|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')] + public function testBetweenPreservesTypedLeafSemantics(array|null $baseline, array|null $target, array $expected): void + { + $originalBaseline = $baseline; + $originalTarget = $target; + + $difference = PayloadDifference::between($baseline, $target); + + self::assertSame( + $expected, + [$difference->added, $difference->removed, $difference->changed, $difference->unchanged], + 'Structural counters must preserve typed values, paths, and capture presence.', + ); + self::assertSame( + $originalBaseline, + $baseline, + 'Comparison must not modify the baseline.', + ); + self::assertSame( + $originalTarget, + $target, + 'Comparison must not modify the target.', + ); + } + + public function testResultRetainsOnlyCounters(): void + { + $difference = PayloadDifference::between( + ['secret' => 'original-value'], + ['secret' => 'other-value'], + ); + + self::assertSame( + ['added' => 0, 'removed' => 0, 'changed' => 1, 'unchanged' => 0], + get_object_vars($difference), + 'The result must expose counters rather than diagnostic values or fingerprints.', + ); + } +} diff --git a/tests/Provider/ToolbarItemProvider.php b/tests/Provider/ToolbarItemProvider.php new file mode 100644 index 0000000..824f0a4 --- /dev/null +++ b/tests/Provider/ToolbarItemProvider.php @@ -0,0 +1,34 @@ + + */ + public static function nullableValues(): iterable + { + yield 'clear' => [null]; + yield 'empty' => ['']; + yield 'raw' => ['&value']; + yield 'zero' => ['0']; + } + + /** + * @return iterable + */ + public static function statusValues(): iterable + { + yield 'empty' => ['']; + yield 'unchanged' => ['success']; + yield 'zero' => ['0']; + } +} diff --git a/tests/Toolbar/ToolbarItemTest.php b/tests/Toolbar/ToolbarItemTest.php new file mode 100644 index 0000000..23072e6 --- /dev/null +++ b/tests/Toolbar/ToolbarItemTest.php @@ -0,0 +1,307 @@ +withLabel('Status') + ->withIcon('request') + ->withStatus('default') + ->withTitle('') + ->withUrl('/debug?tag=0&panel=request') + ->withId('status'); + + self::assertSame( + (new ToolbarItem('0', 'Status', 'request', 'default', '', '/debug?tag=0&panel=request', 'status')) + ->jsonSerialize(), + $item->jsonSerialize(), + 'Fluent construction must preserve the exact serialized field order and raw values.', + ); + } + + #[DataProviderExternal(ToolbarItemProvider::class, 'nullableValues')] + public function testWithIconPreservesOriginalAndOtherFields(string|null $value): void + { + $original = new ToolbarItem('0', 'Label', 'request', 'success', '', '/debug', 'metric'); + + $before = get_object_vars($original); + + $expected = $before; + $expected['icon'] = $value; + + $modified = $original->withIcon($value); + + self::assertNotSame( + $original, + $modified, + 'Configuration must always return a distinct instance.', + ); + self::assertSame( + $before, + get_object_vars($original), + 'Configuration must leave the original unchanged.', + ); + self::assertSame( + $expected, + get_object_vars($modified), + 'Configuration must preserve every other field.', + ); + + $payload = $modified->jsonSerialize(); + + if ($value === null) { + self::assertArrayNotHasKey( + 'icon', + $payload, + 'Only null optional fields must be omitted.', + ); + } else { + self::assertSame( + $value, + $payload['icon'] ?? null, + 'Empty and zero strings must remain present.', + ); + } + } + + #[DataProviderExternal(ToolbarItemProvider::class, 'nullableValues')] + public function testWithIdPreservesOriginalAndOtherFields(string|null $value): void + { + $original = new ToolbarItem('0', 'Label', 'request', 'success', '<title>', '/debug', 'metric'); + + $before = get_object_vars($original); + + $expected = $before; + $expected['id'] = $value; + $modified = $original->withId($value); + + self::assertNotSame( + $original, + $modified, + 'Configuration must always return a distinct instance.', + ); + self::assertSame( + $before, + get_object_vars($original), + 'Configuration must leave the original unchanged.', + ); + self::assertSame( + $expected, + get_object_vars($modified), + 'Configuration must preserve every other field.', + ); + + $payload = $modified->jsonSerialize(); + + if ($value === null) { + self::assertArrayNotHasKey( + 'id', + $payload, + 'Only null optional fields must be omitted.', + ); + } else { + self::assertSame( + $value, + $payload['id'] ?? null, + 'Empty and zero strings must remain present.', + ); + } + } + + #[DataProviderExternal(ToolbarItemProvider::class, 'nullableValues')] + public function testWithLabelPreservesOriginalAndOtherFields(string|null $value): void + { + $original = new ToolbarItem('0', 'Label', 'request', 'success', '<title>', '/debug', 'metric'); + + $before = get_object_vars($original); + + $expected = $before; + $expected['label'] = $value; + + $modified = $original->withLabel($value); + + self::assertNotSame( + $original, + $modified, + 'Configuration must always return a distinct instance.', + ); + self::assertSame( + $before, + get_object_vars($original), + 'Configuration must leave the original unchanged.', + ); + self::assertSame( + $expected, + get_object_vars($modified), + 'Configuration must preserve every other field.', + ); + + $payload = $modified->jsonSerialize(); + + if ($value === null) { + self::assertArrayNotHasKey( + 'label', + $payload, + 'Only null optional fields must be omitted.', + ); + } else { + self::assertSame( + $value, + $payload['label'] ?? null, + 'Empty and zero strings must remain present.', + ); + } + } + + #[DataProviderExternal(ToolbarItemProvider::class, 'statusValues')] + public function testWithStatusPreservesOriginalAndOtherFields(string $value): void + { + $original = new ToolbarItem('0', 'Label', 'request', 'success', '<title>', '/debug', 'metric'); + + $before = get_object_vars($original); + + $expected = $before; + $expected['status'] = $value; + + $modified = $original->withStatus($value); + + self::assertNotSame( + $original, + $modified, + 'Configuration must always return a distinct instance.', + ); + self::assertSame( + $before, + get_object_vars($original), + 'Configuration must leave the original unchanged.', + ); + self::assertSame( + $expected, + get_object_vars($modified), + 'Configuration must preserve every other field.', + ); + + $payload = $modified->jsonSerialize(); + + self::assertSame( + $value, + $payload['status'], + 'Status must be serialized without normalization.', + ); + } + + #[DataProviderExternal(ToolbarItemProvider::class, 'nullableValues')] + public function testWithTitlePreservesOriginalAndOtherFields(string|null $value): void + { + $original = new ToolbarItem('0', 'Label', 'request', 'success', '<title>', '/debug', 'metric'); + + $before = get_object_vars($original); + + $expected = $before; + $expected['title'] = $value; + + $modified = $original->withTitle($value); + + self::assertNotSame( + $original, + $modified, + 'Configuration must always return a distinct instance.', + ); + self::assertSame( + $before, + get_object_vars($original), + 'Configuration must leave the original unchanged.', + ); + self::assertSame( + $expected, + get_object_vars($modified), + 'Configuration must preserve every other field.', + ); + + $payload = $modified->jsonSerialize(); + + if ($value === null) { + self::assertArrayNotHasKey( + 'title', + $payload, + 'Only null optional fields must be omitted.', + ); + } else { + self::assertSame( + $value, + $payload['title'] ?? null, + 'Empty and zero strings must remain present.', + ); + } + } + + #[DataProviderExternal(ToolbarItemProvider::class, 'nullableValues')] + public function testWithUrlPreservesOriginalAndOtherFields(string|null $value): void + { + $original = new ToolbarItem('0', 'Label', 'request', 'success', '<title>', '/debug', 'metric'); + + $before = get_object_vars($original); + + $expected = $before; + $expected['url'] = $value; + + $modified = $original->withUrl($value); + + self::assertNotSame( + $original, + $modified, + 'Configuration must always return a distinct instance.', + ); + self::assertSame( + $before, + get_object_vars($original), + 'Configuration must leave the original unchanged.', + ); + self::assertSame( + $expected, + get_object_vars($modified), + 'Configuration must preserve every other field.', + ); + + $payload = $modified->jsonSerialize(); + + if ($value === null) { + self::assertArrayNotHasKey( + 'url', + $payload, + 'Only null optional fields must be omitted.', + ); + } else { + self::assertSame( + $value, + $payload['url'] ?? null, + 'Empty and zero strings must remain present.', + ); + } + } +} diff --git a/tests/Toolbar/ToolbarPanelTest.php b/tests/Toolbar/ToolbarPanelTest.php new file mode 100644 index 0000000..521716a --- /dev/null +++ b/tests/Toolbar/ToolbarPanelTest.php @@ -0,0 +1,140 @@ +<?php + +declare(strict_types=1); + +namespace PHPForge\Debug\Tests\Toolbar; + +use PHPForge\Debug\Toolbar\{ToolbarItem, ToolbarPanel}; +use PHPUnit\Framework\Attributes\{DataProvider, Group}; +use PHPUnit\Framework\TestCase; + +use function get_object_vars; + +/** + * Unit tests for fluent toolbar panel construction and immutable navigation and metric lists. + */ +#[Group('toolbar')] +final class ToolbarPanelTest extends TestCase +{ + /** + * @return iterable<string, array{string|null}> + */ + public static function nullableValues(): iterable + { + yield 'clear' => [null]; + yield 'empty' => ['']; + yield 'unchanged icon' => ['request']; + yield 'unchanged url' => ['/debug']; + yield 'zero' => ['0']; + } + + public function testCreateMatchesConstructorDefaults(): void + { + self::assertEquals( + new ToolbarPanel('request', 'Request'), + ToolbarPanel::create('request', 'Request'), + 'Factory defaults must match the constructor.', + ); + } + + public function testFluentConstructionMatchesLegacyPayload(): void + { + $items = [new ToolbarItem('0'), new ToolbarItem('')]; + + $panel = ToolbarPanel::create('request', 'Request') + ->withItems($items) + ->withIcon('request') + ->withUrl('/debug?tag=0&panel=request'); + + self::assertSame( + (new ToolbarPanel('request', 'Request', '/debug?tag=0&panel=request', 'request', $items)) + ->jsonSerialize(), + $panel->jsonSerialize(), + 'Fluent construction must preserve serialized fields and metric order.', + ); + } + + public function testWithItemsReplacesRatherThanAppendsAndAllowsClearing(): void + { + $first = new ToolbarItem('first'); + $second = new ToolbarItem('second'); + $original = new ToolbarPanel('request', 'Request', '/debug', 'request', [$first]); + + $items = [$second, $first]; + $modified = $original->withItems($items); + $cleared = $modified->withItems([]); + + $items[] = new ToolbarItem('later'); + + self::assertNotSame( + $original, + $modified, + 'Replacing metrics must return a copy.', + ); + self::assertNotSame( + $modified, + $cleared, + 'Clearing metrics must return a copy.', + ); + self::assertSame( + [$first], + $original->items, + 'Original metrics must remain intact.', + ); + self::assertSame( + [$second, $first], + $modified->items, + 'The replacement list must retain its own order.', + ); + self::assertSame( + (new ToolbarPanel('request', 'Request', '/debug', 'request'))->jsonSerialize(), + $cleared->jsonSerialize(), + 'Clearing metrics must retain navigation and serialize an empty list.', + ); + } + + #[DataProvider('nullableValues')] + public function testWithNavigationPreservesOriginalAndOtherFields(string|null $value): void + { + $original = new ToolbarPanel('request', 'Request', '/debug', 'request', [new ToolbarItem('0')]); + + $before = get_object_vars($original); + + foreach (['url' => $original->withUrl($value), 'icon' => $original->withIcon($value)] as $field => $modified) { + $expected = $before; + $expected[$field] = $value; + + self::assertNotSame( + $original, + $modified, + 'Configuration must return a distinct panel.', + ); + self::assertSame( + $before, + get_object_vars($original), + 'Configuration must leave the original unchanged.', + ); + self::assertSame( + $expected, + get_object_vars($modified), + 'Configuration must preserve every other field.', + ); + + $payload = $modified->jsonSerialize(); + + if ($value === null) { + self::assertArrayNotHasKey( + $field, + $payload, + 'Only null navigation fields must be omitted.', + ); + } else { + self::assertSame( + $value, + $payload[$field] ?? null, + 'Empty and zero strings must remain present.', + ); + } + } + } +}