feat: Pods fields in Theme Builder bodies checked against the template's post types (E12)

Both forms Divi writes are recognized (ruling Q11): custom_meta_<key> and the
dialog form post_meta_key + select_meta_key. Choice fields get a note about the
stored value, relation fields name IDs (F4). Only templates the user may edit
are checked or named; others are counted (Q21 pattern). Warnings only, also in
the dry run of a live body. DynamicFields extracted from lit/divi-dynamic-sources.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 21:02:53 +02:00
co-authored by Claude Opus 5.5
parent a31d4340f9
commit 6be2e65432
15 changed files with 778 additions and 86 deletions
@@ -519,4 +519,12 @@ final class VariableRefTest extends TestCase
$this->expectExceptionMessage('nur in Link- und URL-Feldern');
$ref->canonical($link, ComposeFixture::sanitizer());
}
public function testNameOfReadsTheContentOptionOnly(): void
{
self::assertSame('custom_meta_role', VariableRef::nameOf(self::content('custom_meta_role', '{}')));
self::assertNull(VariableRef::nameOf('$variable({"type":"color","value":{"name":"gcid-primary","settings":{}}})$'));
self::assertNull(VariableRef::nameOf('Text'));
self::assertNull(VariableRef::nameOf('$variable(kaputt)$'));
}
}
@@ -12,6 +12,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
@@ -48,7 +49,7 @@ final class DiviWriterThemeBuilderTest extends TestCase
}
/** @param list<Template> $templates */
private function writer(array $templates, ?VisibleEdits $visible = null): DiviWriter
private function writer(array $templates, ?VisibleEdits $visible = null, ?DynamicFields $fields = null): DiviWriter
{
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates));
@@ -58,7 +59,7 @@ final class DiviWriterThemeBuilderTest extends TestCase
false,
ThemeBuilderFixtures::loader($access),
null,
$visible === null ? new ThemeBuilderEdits($access) : new ThemeBuilderEdits($access, $visible)
new ThemeBuilderEdits($access, $visible ?? new VisibleEdits(), $fields)
);
}
@@ -192,4 +193,33 @@ final class DiviWriterThemeBuilderTest extends TestCase
self::assertSame(1065, $entry['templates'][0]['id']);
self::assertSame([], $this->saved);
}
private static function unknownFields(): DynamicFields
{
$fields = Mockery::mock(DynamicFields::class);
$fields->allows('reason')->andReturn('kein bekanntes Feld dieses Inhaltstyps');
$fields->allows('note')->andReturnNull();
return $fields;
}
/** E12: an unsuitable field in a body is a warning – saved anyway; the dry run of a live body shows it too. */
public function testFieldWarningsInTheResultAndInTheDryRun(): void
{
$field = ['nodes' => [['type' => 'text', 'content' => '{{field:telefon}}']]];
$disabled = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']]);
$result = (new DiviInsertAbility($this->writer([$disabled], null, self::unknownFields())))
->execute($this->insert($this->body, $field));
self::assertTrue($result['ok']);
self::assertCount(1, $this->saved);
self::assertStringContainsString('„telefon“', implode(' ', $result['warnings']));
$live = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'use_on' => ['singular:post_type:lit_person:all']]);
$dry = (new DiviInsertAbility($this->writer([$live], null, self::unknownFields())))
->execute($this->insert($this->body, $field));
self::assertToolError($dry, 'confirm_required');
self::assertStringContainsString('„telefon“', implode(' ', $dry->get_error_data()['warnings']));
self::assertCount(1, $this->saved);
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DynamicFieldsTest extends TestCase
{
private static function fields(): DynamicFields
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->with('lit_person')->andReturn([
new FieldDefinition('role', 'Funktion', 'string', 'pods', FieldDefinition::WRITABLE),
new FieldDefinition('level', 'Stufe', 'choice', 'pods', FieldDefinition::WRITABLE),
new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', FieldDefinition::WRITABLE),
]);
return new DynamicFields($schema, static fn (string $type): ?string => 'meta');
}
public function testTextLikeFieldsAreOfferedOthersOmittedWithTheirReason(): void
{
[$offered, $omitted] = self::fields()->offered('lit_person');
self::assertSame(['role', 'level'], array_column($offered, 'key'));
self::assertSame('{{field:role}}', $offered[0]['placeholder']);
self::assertSame(['mentor'], array_column($omitted, 'key'));
}
public function testReasonForOneKey(): void
{
self::assertNull(self::fields()->reason('lit_person', 'role'));
self::assertStringContainsString('nur IDs', (string) self::fields()->reason('lit_person', 'mentor'));
self::assertSame('kein bekanntes Feld dieses Inhaltstyps', self::fields()->reason('lit_person', 'telefon'));
}
/** Answer F4 / stage3b render: a choice field shows its stored value ("senior"), not the label. */
public function testChoiceFieldsGetANoteAboutTheStoredValue(): void
{
self::assertStringContainsString('gespeicherten Wert', (string) self::fields()->note('lit_person', 'level'));
self::assertNull(self::fields()->note('lit_person', 'role'));
self::assertNull(self::fields()->note('lit_person', 'mentor'));
self::assertNull(self::fields()->note('lit_person', 'telefon'));
}
private static function content(string $name, array $settings): string
{
return VariableRef::serialize('content', $name, $settings);
}
/** Ruling Q11 / stage3b finding 12: both forms Divi resolves (DynamicContentOptionPostMetaKey.php:222-262). */
public function testMetaKeyOfReadsBothFormsDiviWrites(): void
{
self::assertSame('role', DynamicFields::metaKeyOf(self::content('custom_meta_role', ['before' => ''])));
self::assertSame('role', DynamicFields::metaKeyOf(self::content('post_meta_key', [
'before' => '',
'after' => '',
'select_meta_key' => 'custom_meta_role',
'meta_key' => '',
'date_format' => 'default',
'custom_date_format' => '',
'enable_html' => 'off',
])));
self::assertSame('telefon', DynamicFields::metaKeyOf(self::content('post_meta_key', ['meta_key' => 'telefon'])));
self::assertSame('telefon', DynamicFields::metaKeyOf(self::content('post_meta_key', [
'select_meta_key' => 'custom_meta_manual_custom_field_value',
'meta_key' => 'telefon',
])));
self::assertSame('telefon', DynamicFields::metaKeyOf(self::content(
'custom_meta_manual_custom_field_value',
['meta_key' => 'telefon']
)));
}
public function testMetaKeyOfIgnoresEverythingElse(): void
{
self::assertNull(DynamicFields::metaKeyOf(self::content('post_title', ['before' => ''])));
self::assertNull(DynamicFields::metaKeyOf(self::content('post_meta_key', ['meta_key' => ''])));
self::assertNull(DynamicFields::metaKeyOf(self::content('post_meta_key', ['select_meta_key' => 'role'])));
self::assertNull(DynamicFields::metaKeyOf(self::content('custom_meta_', [])));
self::assertNull(DynamicFields::metaKeyOf(
'$variable({"type":"color","value":{"name":"custom_meta_role","settings":{}}})$'
));
self::assertNull(DynamicFields::metaKeyOf('custom_meta_role'));
}
/** The real builder output (stage3b body 2391) names role, level and mentor. */
public function testMetaKeysOfTheRealBuilderBody(): void
{
$content = DiviFixtures::markup('stage3b/tb-body--person-body.html');
preg_match_all('/"value":"(\$variable\(.*?\)\$)"/', $content, $matches);
$keys = [];
foreach ($matches[1] as $value) {
$key = DynamicFields::metaKeyOf((string) json_decode('"' . $value . '"'));
if ($key !== null) {
$keys[] = $key;
}
}
self::assertSame(['role', 'level', 'mentor'], $keys);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\TestCase;
/** The body layout the real builder wrote with Pods fields (stage3b) stays editable and byte-identical. */
final class Stage3bBodyEditTest extends TestCase
{
public function testChangingALabelKeepsEveryOtherByteIncludingThePodsReferences(): void
{
$content = DiviFixtures::markup('stage3b/tb-body--person-body.html');
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY, ['post_content' => $content]);
LibraryFixtures::given([$body]);
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
$saved = [];
$failures = [];
$access = ThemeBuilderFixtures::access();
$writer = DiviWriterFixture::writer(
$saved,
$failures,
false,
ThemeBuilderFixtures::loader($access),
null,
new ThemeBuilderEdits($access)
);
$result = (new DiviUpdateAbility($writer))->execute([
'post_id' => 1062,
'tree_rev' => Rev::of($content),
'changes' => [['id' => 's0.r0.c0.m0', 'label' => 'Person Name']],
]);
self::assertTrue($result['ok']);
self::assertSame(str_replace('"value":"Person Titel"', '"value":"Person Name"', $content), $saved[0]);
}
}
@@ -7,12 +7,14 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure;
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbCreateTemplateAbility;
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LibraryFixtures;
@@ -86,10 +88,22 @@ final class TbCreateTemplateAbilityTest extends TestCase
new TemplateConditions($store),
DiviWriterFixture::composition(DiviWriterFixture::designReader()),
$saver,
$log ?? DiviWriterFixture::log()
$log ?? DiviWriterFixture::log(),
new ThemeBuilderEdits(ThemeBuilderFixtures::access([], true, $store), fields: self::personFields())
);
}
private static function personFields(): DynamicFields
{
$fields = Mockery::mock(DynamicFields::class);
$fields->allows('reason')->andReturnUsing(
static fn (string $type, string $key): ?string => $key === 'role' ? null : 'kein bekanntes Feld dieses Inhaltstyps'
);
$fields->allows('note')->andReturnNull();
return $fields;
}
/** @return array<string, mixed> */
private static function input(array $extra = []): array
{
@@ -441,4 +455,20 @@ final class TbCreateTemplateAbilityTest extends TestCase
self::assertToolError($result, 'validation_failed');
self::assertArrayHasKey('header.source', self::errorFields($result));
}
public function testAnUnknownFieldInTheNewBodyIsAWarning(): void
{
$store = self::store();
self::expectCreation($store);
$store->shouldReceive('attach')->once();
$store->shouldReceive('clearCaches')->once();
$input = self::input();
$input['body']['nodes'][0]['modules'][] = ['type' => 'text', 'content' => '{{field:telefon}}'];
$result = $this->ability($store)->execute($input);
self::assertTrue($result['ok']);
self::assertNotSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, '„telefon“')));
self::assertSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, '„role“')));
}
}
@@ -5,9 +5,13 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
@@ -15,6 +19,7 @@ use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class ThemeBuilderEditsTest extends TestCase
@@ -184,4 +189,147 @@ final class ThemeBuilderEditsTest extends TestCase
self::assertSame(0, $this->backups);
}
/** @param array<string, mixed> $settings */
private static function textWith(string $option, array $settings = ['before' => '', 'after' => '']): Block
{
return Block::create('divi/text', ['content' => ['innerContent' => ['desktop' => [
'value' => VariableRef::serialize('content', $option, $settings),
]]]]);
}
/** Divi's dialog form of a Pods field (stage3b README finding 12, ruling Q11). */
private static function dialogText(string $key): Block
{
return self::textWith('post_meta_key', [
'before' => '',
'after' => '',
'select_meta_key' => 'custom_meta_' . $key,
'meta_key' => '',
'date_format' => 'default',
'custom_date_format' => '',
'enable_html' => 'off',
]);
}
/** @var list<array{string, string}> */
private array $asked = [];
private function fields(): DynamicFields
{
$fields = Mockery::mock(DynamicFields::class);
$fields->allows('reason')->andReturnUsing(function (string $type, string $key): ?string {
$this->asked[] = [$type, $key];
return match ($key) {
'role', 'level' => null,
'mentor' => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
default => 'kein bekanntes Feld dieses Inhaltstyps',
};
});
$fields->allows('note')->andReturnUsing(static fn (string $type, string $key): ?string => $key === 'level'
? 'Auswahlfeld – Divi zeigt den gespeicherten Wert, nicht die Beschriftung'
: null);
return $fields;
}
/** @param list<Template> $templates */
private function checking(array $templates): ThemeBuilderEdits
{
return new ThemeBuilderEdits(
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates)),
fields: $this->fields()
);
}
/** E12 / answer F4: fields are checked against the post types of the templates that use the body. */
public function testFieldWarningsNameUnsuitableFieldsOfTheTemplatesPostTypes(): void
{
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']])]);
$change = (new DiviChange('2 Divi-Knoten eingefügt'))
->record(self::textWith('custom_meta_role'), 'inserted')
->record(self::textWith('custom_meta_mentor'), 'inserted');
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertCount(1, $warnings);
self::assertStringContainsString('„mentor“', $warnings[0]);
self::assertStringContainsString('lit_person: Beziehungs', $warnings[0]);
self::assertSame([], $edits->fieldWarnings(self::page(ThemeBuilderFixtures::HEADER, 'header'), $change));
}
/** Ruling Q11: Divi's dialog form (post_meta_key + select_meta_key) is checked like custom_meta_<key>. */
public function testTheDialogFormIsCheckedToo(): void
{
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']])]);
$change = (new DiviChange('x'))
->record(self::dialogText('role'), 'changed')
->record(self::dialogText('telefon'), 'changed');
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertCount(1, $warnings);
self::assertStringContainsString('„telefon“', $warnings[0]);
self::assertSame([['lit_person', 'role'], ['lit_person', 'telefon']], $this->asked);
}
/** Answer F4: a choice field shows its stored value – said once per field, with the post type. */
public function testChoiceFieldsNameTheStoredValue(): void
{
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']])]);
$change = (new DiviChange('x'))->record(self::dialogText('level'), 'changed');
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertCount(1, $warnings);
self::assertStringContainsString('„level“', $warnings[0]);
self::assertStringContainsString('lit_person: Auswahlfeld – Divi zeigt den gespeicherten Wert', $warnings[0]);
}
public function testWithoutAPostTypeConditionTheFieldsAreUnchecked(): void
{
$edits = $this->checking([ThemeBuilderFixtures::template(1066, ['use_on' => ['homepage']])]);
$change = (new DiviChange('x'))->record(self::textWith('custom_meta_role'), 'inserted');
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertCount(1, $warnings);
self::assertStringContainsString('ungeprüft', $warnings[0]);
self::assertSame([], $this->asked);
}
public function testNodesWithoutFieldsOrWithoutAFieldSourceGiveNoWarning(): void
{
$template = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']]);
$plain = (new DiviChange('x'))->record(self::textWith('post_title'), 'inserted');
self::assertSame([], $this->checking([$template])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $plain));
$fields = (new DiviChange('x'))->record(self::textWith('custom_meta_telefon'), 'inserted');
self::assertSame([], self::edits([$template])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $fields));
}
/** Ruling Q21 pattern: templates the user may not edit are counted, never their post types. */
public function testTemplatesTheUserMayNotEditLeakNoPostType(): void
{
Functions\when('current_user_can')->alias(static fn (string $cap, mixed $id = null): bool => $id !== 1067);
$mine = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']]);
$theirs = ThemeBuilderFixtures::template(1067, ['use_on' => ['singular:post_type:geheim:all']]);
$change = (new DiviChange('x'))->record(self::textWith('custom_meta_telefon'), 'inserted');
$warnings = $this->checking([$mine, $theirs])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertSame([['lit_person', 'telefon']], $this->asked);
self::assertStringNotContainsString('geheim', implode(' ', $warnings));
self::assertStringNotContainsString('1067', implode(' ', $warnings));
self::assertStringContainsString('ohne Berechtigung', implode(' ', $warnings));
$this->asked = [];
$only = $this->checking([$theirs])->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
self::assertSame([], $this->asked);
self::assertCount(1, $only);
self::assertStringContainsString('ungeprüft', $only[0]);
self::assertStringContainsString('ohne Berechtigung', $only[0]);
self::assertStringNotContainsString('geheim', $only[0]);
}
}