refactor(form-field): extract legacy conditional_logic shape normaliser

Three byte-identical copies of `normaliseLegacyGroupShape` lived in
FormFieldService, StoreFormFieldRequest, and UpdateFormFieldRequest.
WS-5d (form_fields.options) would have been the fourth copy. Hoist
the helper to a single public static on FormFieldConditionalLogicService
and have all three call sites delegate.

Implementation:

  - `FormFieldConditionalLogicService::normaliseLegacyShape(array)` —
    pure recursive passthrough. Translates the ARCH §8 JSON group shape
    (`{"all": [...]}` / `{"any": [...]}`) into the service's internal
    `{"operator", "children"}` form. Does NOT validate; malformed shapes
    return as-is and surface downstream as
    `InvalidConditionalLogicSpecException` from `assertSpecsValid`.
  - Group operator catalogue sourced from
    `FormFieldConditionalLogicGroupOperator::values()` instead of an
    `['all', 'any']` literal — single source of truth for future
    operator additions.
  - All three call sites switched to the static method. The two
    FormRequests reach it via the existing `use` import; FormFieldService
    sits in the same namespace.

Behaviour preserved exactly:

  - Existing FormFieldApiTest (cyclic logic rejection),
    FormFieldStrictConditionalLogicRequestTest (strict-validator
    rejection paths), and FormFieldConditionalLogicServiceTest
    (service-level paths) all green without modification.

New unit tests pin the passthrough contract (8 tests):

  - Valid ALL / ANY translations
  - Recursive nested-group translation (depth 2)
  - Internal shape unchanged
  - Condition leaf passthrough
  - Unknown group key (`xor`) returned unchanged for downstream
    `assertSpecsValid` to reject
  - Empty array unchanged
  - Non-array children stripped silently

Tests: 1150 → 1158 green (3110 → 3124 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:57:06 +02:00
parent 64f5855fdb
commit 2656818c35
6 changed files with 205 additions and 115 deletions

View File

@@ -14,4 +14,16 @@ enum FormFieldConditionalLogicGroupOperator: string
{
case All = 'all';
case Any = 'any';
/**
* String values of every case used by the legacy-shape normaliser
* to walk `{"all": [...]}` / `{"any": [...]}` JSON nodes without
* duplicating the operator catalogue at the call site.
*
* @return list<string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@@ -99,7 +99,7 @@ final class StoreFormFieldRequest extends FormRequest
$root = isset($logic['show_when']) && is_array($logic['show_when'])
? $logic['show_when']
: $logic;
$normalised = $this->normaliseLegacyGroupShape($root);
$normalised = FormFieldConditionalLogicService::normaliseLegacyShape($root);
try {
app(FormFieldConditionalLogicService::class)->assertSpecsValid($normalised);
} catch (InvalidConditionalLogicSpecException $e) {
@@ -108,44 +108,4 @@ final class StoreFormFieldRequest extends FormRequest
},
];
}
/**
* Mirror of FormFieldService::normaliseLegacyGroupShape translates
* the ARCH §8 JSON group shape (`{"all": [...]}` / `{"any": [...]}`)
* to the service's internal `{"operator", "children"}` form so the
* boundary validator can reuse the service's canonical assertion.
*
* @param array<string, mixed> $node
* @return array<string, mixed>
*/
private function normaliseLegacyGroupShape(array $node): array
{
if (isset($node['field_slug'])) {
return $node;
}
if (isset($node['operator'], $node['children']) && is_array($node['children'])) {
$children = [];
foreach ($node['children'] as $child) {
if (is_array($child)) {
$children[] = $this->normaliseLegacyGroupShape($child);
}
}
return ['operator' => $node['operator'], 'children' => $children];
}
foreach (['all', 'any'] as $candidate) {
if (isset($node[$candidate]) && is_array($node[$candidate])) {
$children = [];
foreach ($node[$candidate] as $child) {
if (is_array($child)) {
$children[] = $this->normaliseLegacyGroupShape($child);
}
}
return ['operator' => $candidate, 'children' => $children];
}
}
return $node;
}
}

View File

@@ -102,7 +102,7 @@ final class UpdateFormFieldRequest extends FormRequest
$root = isset($logic['show_when']) && is_array($logic['show_when'])
? $logic['show_when']
: $logic;
$normalised = $this->normaliseLegacyGroupShape($root);
$normalised = FormFieldConditionalLogicService::normaliseLegacyShape($root);
try {
app(FormFieldConditionalLogicService::class)->assertSpecsValid($normalised);
} catch (InvalidConditionalLogicSpecException $e) {
@@ -111,39 +111,4 @@ final class UpdateFormFieldRequest extends FormRequest
},
];
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
*/
private function normaliseLegacyGroupShape(array $node): array
{
if (isset($node['field_slug'])) {
return $node;
}
if (isset($node['operator'], $node['children']) && is_array($node['children'])) {
$children = [];
foreach ($node['children'] as $child) {
if (is_array($child)) {
$children[] = $this->normaliseLegacyGroupShape($child);
}
}
return ['operator' => $node['operator'], 'children' => $children];
}
foreach (['all', 'any'] as $candidate) {
if (isset($node[$candidate]) && is_array($node[$candidate])) {
$children = [];
foreach ($node[$candidate] as $child) {
if (is_array($child)) {
$children[] = $this->normaliseLegacyGroupShape($child);
}
}
return ['operator' => $candidate, 'children' => $children];
}
}
return $node;
}
}

View File

@@ -115,6 +115,57 @@ final class FormFieldConditionalLogicService
$this->assertNodeValid($tree, isRoot: true);
}
/**
* Translate the legacy ARCH §8 JSON group shape (`{"all": [...]}` /
* `{"any": [...]}`) into the service's internal tree form
* (`{"operator": "all"|"any", "children": [...]}`). Pure recursive
* passthrough does NOT validate; malformed shapes are returned
* as-is and surface as `InvalidConditionalLogicSpecException` from
* the downstream `assertSpecsValid`. Static so both the service path
* (`FormFieldService::extractConditionalLogicTree`) and the boundary
* validators (Store/Update FormRequests' `after()` hooks) can call
* it without container resolution.
*
* Group operator catalogue is sourced from
* `FormFieldConditionalLogicGroupOperator::values()` so a future
* operator addition lands in one place.
*
* @param array<string, mixed> $node
* @return array<string, mixed>
*/
public static function normaliseLegacyShape(array $node): array
{
if (isset($node['field_slug'])) {
return $node;
}
if (isset($node['operator'], $node['children']) && is_array($node['children'])) {
$children = [];
foreach ($node['children'] as $child) {
if (is_array($child)) {
$children[] = self::normaliseLegacyShape($child);
}
}
return ['operator' => $node['operator'], 'children' => $children];
}
foreach (FormFieldConditionalLogicGroupOperator::values() as $candidate) {
if (isset($node[$candidate]) && is_array($node[$candidate])) {
$children = [];
foreach ($node[$candidate] as $child) {
if (is_array($child)) {
$children[] = self::normaliseLegacyShape($child);
}
}
return ['operator' => $candidate, 'children' => $children];
}
}
return $node;
}
/**
* Cross-field cycle detection (ARCH §8; contract preserved from the
* pre-WS-5c `FormFieldService::assertNoConditionalCycle`). Builds a

View File

@@ -367,44 +367,7 @@ final class FormFieldService
$rootGroup = $raw;
}
return [$this->normaliseLegacyGroupShape($rootGroup), true];
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
*/
private function normaliseLegacyGroupShape(array $node): array
{
if (isset($node['field_slug'])) {
return $node;
}
if (isset($node['operator'], $node['children']) && is_array($node['children'])) {
$children = [];
foreach ($node['children'] as $child) {
if (is_array($child)) {
$children[] = $this->normaliseLegacyGroupShape($child);
}
}
return ['operator' => $node['operator'], 'children' => $children];
}
foreach (['all', 'any'] as $candidate) {
if (isset($node[$candidate]) && is_array($node[$candidate])) {
$children = [];
foreach ($node[$candidate] as $child) {
if (is_array($child)) {
$children[] = $this->normaliseLegacyGroupShape($child);
}
}
return ['operator' => $candidate, 'children' => $children];
}
}
return $node;
return [FormFieldConditionalLogicService::normaliseLegacyShape($rootGroup), true];
}
/**

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\FormBuilder;
use App\Services\FormBuilder\FormFieldConditionalLogicService;
use PHPUnit\Framework\TestCase;
/**
* Unit-level coverage for the `normaliseLegacyShape` static helper
* extracted in WS-5c commit 6 from three byte-identical copies (one in
* FormFieldService, two in the Store/Update FormRequests). The method
* is pure passthrough: malformed shapes are returned as-is, and surface
* downstream as `InvalidConditionalLogicSpecException` from
* `assertSpecsValid`. We pin that contract here.
*/
final class FormFieldConditionalLogicNormaliserTest extends TestCase
{
public function test_translates_all_group_to_internal_form(): void
{
$result = FormFieldConditionalLogicService::normaliseLegacyShape([
'all' => [
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
],
]);
$this->assertSame([
'operator' => 'all',
'children' => [
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
],
], $result);
}
public function test_translates_any_group_to_internal_form(): void
{
$result = FormFieldConditionalLogicService::normaliseLegacyShape([
'any' => [
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'],
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'],
],
]);
$this->assertSame('any', $result['operator']);
$this->assertCount(2, $result['children']);
}
public function test_recursively_normalises_nested_groups(): void
{
$result = FormFieldConditionalLogicService::normaliseLegacyShape([
'all' => [
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
[
'any' => [
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'],
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'],
],
],
],
]);
$this->assertSame('all', $result['operator']);
$this->assertCount(2, $result['children']);
// First child: condition leaf — passthrough.
$this->assertSame(
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
$result['children'][0],
);
// Second child: nested ANY group — translated.
$this->assertSame('any', $result['children'][1]['operator']);
$this->assertCount(2, $result['children'][1]['children']);
}
public function test_passes_through_internal_shape_unchanged(): void
{
$internal = [
'operator' => 'all',
'children' => [
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
],
];
$this->assertSame($internal, FormFieldConditionalLogicService::normaliseLegacyShape($internal));
}
public function test_passes_through_condition_leaf_unchanged(): void
{
// Conditions at root are technically invalid (a tree must start
// with a group); the normaliser returns the leaf as-is and the
// downstream `assertSpecsValid` produces the 422 error.
$leaf = ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'];
$this->assertSame($leaf, FormFieldConditionalLogicService::normaliseLegacyShape($leaf));
}
public function test_returns_unknown_group_key_as_passthrough_for_downstream_assert(): void
{
// `xor` is not in the FormFieldConditionalLogicGroupOperator enum.
// Normaliser returns the node unchanged; assertSpecsValid will
// raise `InvalidConditionalLogicSpecException` with "Unknown
// group operator ''" because the passthrough lacks `operator`.
$node = [
'xor' => [
['field_slug' => 'a', 'operator' => 'equals', 'value' => 1],
],
];
$this->assertSame($node, FormFieldConditionalLogicService::normaliseLegacyShape($node));
}
public function test_returns_empty_array_unchanged(): void
{
$this->assertSame([], FormFieldConditionalLogicService::normaliseLegacyShape([]));
}
public function test_skips_non_array_children_silently(): void
{
// Defensive: malformed children that are scalars get stripped
// (existing behaviour — `if (is_array($child))` guard). Surfaces
// downstream as a "group requires at least one child" error if
// every child was scalar.
$result = FormFieldConditionalLogicService::normaliseLegacyShape([
'all' => [
'string-not-an-array',
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
42,
],
]);
$this->assertCount(1, $result['children']);
$this->assertSame(
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
$result['children'][0],
);
}
}