Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
178 lines
7.3 KiB
PHP
178 lines
7.3 KiB
PHP
<?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\Pages\DiviContentComposer;
|
|
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
|
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
|
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
|
|
{
|
|
$design = DiviWriterFixture::designReader();
|
|
$map = new DynamicContentMap(function (int $postId) use ($registry): array {
|
|
$this->registryCalls[] = $postId;
|
|
|
|
return $registry !== null ? $registry($postId) : DiviFixtures::dynamicContentRegistry();
|
|
});
|
|
|
|
return new DiviContentComposer(
|
|
DiviFixtures::schema($diviEnabled),
|
|
DiviWriterFixture::composition($design, false, $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 testRequirementsNameTheCapabilitiesANewEntryNeeds(): void
|
|
{
|
|
$requirements = $this->composer()->requirements();
|
|
sort($requirements);
|
|
|
|
self::assertSame(['catalog', 'design-read', 'dynamic-sources', 'page-read', 'page-write'], $requirements);
|
|
foreach ($requirements as $id) {
|
|
self::assertNotNull(DiviFeature::tryFrom($id), $id);
|
|
}
|
|
}
|
|
|
|
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
|
|
{
|
|
\Brain\Monkey\Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=42');
|
|
\Brain\Monkey\Functions\when('add_query_arg')->alias(
|
|
static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value
|
|
);
|
|
$written = $this->composer()->write(42, 'page', self::sections());
|
|
|
|
self::assertIsArray($written);
|
|
self::assertSame('https://kunde.test/?page_id=42&et_fb=1', $written['edit_url'], 'review I-1: builder URL');
|
|
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::assertToolError($disabled, 'validation_failed');
|
|
self::assertArrayHasKey('divi', self::errorFields($disabled));
|
|
|
|
$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::assertToolError($invalid, 'invalid_attr_path');
|
|
$fields = self::errorFields($invalid);
|
|
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 = DiviFixtures::dynamicContentRegistry();
|
|
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::assertToolError($written, 'validation_failed');
|
|
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());
|
|
}
|
|
|
|
public function testWritableFollowsTheGateAndDefaultsToTrue(): void
|
|
{
|
|
self::assertTrue($this->composer()->writable());
|
|
|
|
$design = DiviWriterFixture::designReader();
|
|
$gated = new DiviContentComposer(
|
|
DiviFixtures::schema(true),
|
|
DiviWriterFixture::composition($design),
|
|
$this->saver,
|
|
$design,
|
|
static fn (): bool => false
|
|
);
|
|
self::assertFalse($gated->writable());
|
|
}
|
|
}
|