feat: lit/content-create with Divi short form

New input divi (1-30 sections, short form like lit-divi-insert) builds a Divi 5
layout in one divi/placeholder. The whole description is checked before the
insert (Divi's map for "no entry yet"); the draft is inserted with its normal
WordPress fields only, then DiviContentComposer composes again bound to the new
entry and stores it via DiviSaver::saveNew (same kses window as save(), strict
DiviFlags). A refusal or failure deletes the draft. Result adds tree_rev and the
outline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 16:46:50 +02:00
co-authored by Claude Opus 5.5
parent 3efcfaabec
commit 5965e32acb
12 changed files with 861 additions and 23 deletions
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
use RuntimeException;
use WP_Error;
final class DiviContentComposerTest extends TestCase
{
/** @var list<int> Post IDs the dynamic content registry was asked for. */
private array $registryCalls = [];
/** @var array<int, string> Content handed to DiviSaver::saveNew() by post ID. */
private array $saved = [];
/** @var DiviSaver&MockInterface */
private DiviSaver $saver;
protected function setUp(): void
{
parent::setUp();
$this->saver = Mockery::mock(DiviSaver::class);
$this->saver->allows('saveNew')->andReturnUsing(function (int $postId, string $content): array {
$this->saved[$postId] = $content;
return [];
});
}
/**
* @param (callable(int): array<string, mixed>)|null $registry Divi's dynamic content options per post ID.
*/
private function composer(bool $diviEnabled = true, ?callable $registry = null): DiviContentComposer
{
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
$modules->allows('builderVersion')->andReturn('5.2.1');
$design = Mockery::mock(DesignSystemReader::class);
$design->allows('read')->andReturn(DesignSystemTest::sample());
$map = new DynamicContentMap(function (int $postId) use ($registry): array {
$this->registryCalls[] = $postId;
return $registry !== null ? $registry($postId) : DynamicContentMapTest::registry();
});
return new DiviContentComposer(
DiviPageLoaderTest::schema($diviEnabled),
new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $map),
$this->saver,
$design
);
}
/** @return list<array<string, mixed>> */
private static function sections(): array
{
return [
['type' => 'section', 'label' => 'Hero', 'modules' => [['type' => 'heading', 'text' => 'Willkommen']]],
['type' => 'section', 'modules' => [['type' => 'image', 'src' => 'https://kunde.test/a.jpg']]],
];
}
public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void
{
self::assertNull($this->composer()->check('page', self::sections()));
self::assertSame([0], $this->registryCalls, 'Divi\'s options for "no entry yet" (post ID 0)');
self::assertSame([], $this->saved, 'check writes nothing');
}
public function testWritesAPlaceholderWrappedLayoutBoundToTheNewEntry(): void
{
$written = $this->composer()->write(42, 'page', self::sections());
self::assertIsArray($written);
self::assertSame([42], $this->registryCalls, 'the final layout uses the live map of the new entry');
$content = $this->saved[42];
// Ruling S9: exactly one divi/placeholder around the sections, as TreeEditor does for empty pages.
self::assertStringStartsWith('<!-- wp:divi/placeholder --><!-- wp:divi/section ', $content);
self::assertStringEndsWith('<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->', $content);
self::assertSame(1, substr_count($content, '<!-- wp:divi/placeholder -->'));
$document = (new Parser())->parse($content);
self::assertCount(1, $document->children());
self::assertCount(2, $document->children()[0]->children());
self::assertContains('s1.r0.c0.m0: Bild ohne Alt-Text.', $written['warnings']);
self::assertStringContainsString('s0 section "Hero"', (string) $written['outline']);
}
public function testRefusesTypesWithoutBuilderAndInvalidNodes(): void
{
$disabled = $this->composer(false)->check('post', [['type' => 'section']]);
self::assertInstanceOf(WP_Error::class, $disabled);
self::assertSame('lit_validation_failed', $disabled->get_error_code());
self::assertArrayHasKey('divi', $disabled->get_error_data()['fields']);
$invalid = $this->composer()->check('page', [
['type' => 'section', 'modules' => [['type' => 'button', 'attrs' => ['x.y' => ['desktop' => 1]]]]],
['type' => 'section', 'modules' => [['type' => 'button', 'url' => 'javascript:alert(1)', 'text' => 'x']]],
]);
self::assertInstanceOf(WP_Error::class, $invalid);
self::assertSame('lit_invalid_attr_path', $invalid->get_error_code());
$fields = $invalid->get_error_data()['fields'];
self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.x.y', $fields);
self::assertGreaterThanOrEqual(2, count($fields), 'every error of every section comes back');
self::assertSame([], $this->saved);
}
/** The entry's own map can be narrower than the one for "no entry": then nothing is written. */
public function testWriteRefusesWhatTheNewEntrysMapDoesNotOffer(): void
{
$registry = static function (int $postId): array {
$options = DynamicContentMapTest::registry();
if ($postId !== 0) {
unset($options['site_title']);
}
return $options;
};
$title = VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']);
$sections = [['type' => 'section', 'modules' => [['type' => 'heading', 'text' => $title]]]];
self::assertNull($this->composer(true, $registry)->check('page', $sections));
$written = $this->composer(true, $registry)->write(42, 'page', $sections);
self::assertInstanceOf(WP_Error::class, $written);
self::assertSame('lit_validation_failed', $written->get_error_code());
self::assertSame([], $this->saved);
}
public function testAFailingSaveIsThrown(): void
{
$this->saver = Mockery::mock(DiviSaver::class);
$this->saver->allows('saveNew')->andThrow(new RuntimeException('Divi flag missing'));
$this->expectException(RuntimeException::class);
$this->composer()->write(42, 'page', self::sections());
}
}