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
@@ -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]);
}
}