refactor: small Divi tree helpers out of DiviWriter (M3)
Block::isPlaceholder replaces the divi/placeholder literals (ledger T4); NodeIndex::parentBlock/sectionLabel replace the two parentBlock copies and DiviWriter::sectionLabel; DiviChange::idOf and Outline::summary replace the duplicated id resolution and outline text; DiviSchema holds the shared input properties. Behaviour unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -121,6 +121,12 @@ final class Block extends Container
|
||||
return str_starts_with($this->name, 'divi/');
|
||||
}
|
||||
|
||||
/** The divi/placeholder wrapper the builder puts around a page's sections (transparent for IDs). */
|
||||
public function isPlaceholder(): bool
|
||||
{
|
||||
return $this->name === 'divi/placeholder';
|
||||
}
|
||||
|
||||
/** A global module reference or global layout (edited only in Etappe 3). */
|
||||
public function isGlobal(): bool
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ final class NodeIndex
|
||||
{
|
||||
$root = $document;
|
||||
foreach ($document->children() as $block) {
|
||||
if ($block->name() === 'divi/placeholder') {
|
||||
if ($block->isPlaceholder()) {
|
||||
$root = $block;
|
||||
break;
|
||||
}
|
||||
@@ -92,6 +92,26 @@ final class NodeIndex
|
||||
return $parent instanceof Block ? $this->idOf($parent) : null;
|
||||
}
|
||||
|
||||
/** The parent node of $block; null at the top level (the root or a placeholder holds it). */
|
||||
public function parentBlock(Block $block): ?Block
|
||||
{
|
||||
$parent = $this->parentOf($block);
|
||||
|
||||
return $parent instanceof Block && ! $parent->isPlaceholder() ? $parent : null;
|
||||
}
|
||||
|
||||
/** Admin label of the section around $block (or $block itself), for automatic labels ("" when none). */
|
||||
public function sectionLabel(?Block $block): string
|
||||
{
|
||||
for ($node = $block; $node !== null; $node = $this->parentBlock($node)) {
|
||||
if ($node->name() === 'divi/section') {
|
||||
return NodeSummary::label($node);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @return list<array{id: string, block: Block, depth: int}> In document order. */
|
||||
public function entries(): array
|
||||
{
|
||||
@@ -102,7 +122,7 @@ final class NodeIndex
|
||||
private function walk(Container $container, string $prefix, int $depth, array &$counters): void
|
||||
{
|
||||
foreach ($container->children() as $block) {
|
||||
if ($prefix === '' && $block->name() === 'divi/placeholder') {
|
||||
if ($prefix === '' && $block->isPlaceholder()) {
|
||||
$this->walk($block, '', $depth, $counters);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,29 @@ final class Outline
|
||||
return $this->truncated;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outline text of a write result: the whole page ($sectionIds null) or the given
|
||||
* sections one after another (none: text null), and whether any breadth cap fired (S18).
|
||||
*
|
||||
* @param list<string>|null $sectionIds
|
||||
* @return array{text: ?string, truncated: bool}
|
||||
*/
|
||||
public function summary(NodeIndex $index, ?array $sectionIds = null): array
|
||||
{
|
||||
if ($sectionIds === []) {
|
||||
return ['text' => null, 'truncated' => false];
|
||||
}
|
||||
|
||||
$texts = [];
|
||||
$truncated = false;
|
||||
foreach ($sectionIds ?? [null] as $sectionId) {
|
||||
$texts[] = $this->text($this->rows($index, $sectionId));
|
||||
$truncated = $truncated || $this->wasTruncated();
|
||||
}
|
||||
|
||||
return ['text' => implode("\n", $texts), 'truncated' => $truncated];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: string, type: string, label: string, excerpt: string, info: string,
|
||||
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool}>
|
||||
|
||||
@@ -65,7 +65,7 @@ final class TreeEditor
|
||||
|
||||
return [
|
||||
'container' => $container,
|
||||
'parent' => $container instanceof Block && $container->name() !== 'divi/placeholder' ? $container : null,
|
||||
'parent' => $container instanceof Block && ! $container->isPlaceholder() ? $container : null,
|
||||
'index' => $key === 'before' ? $sibling : $sibling + 1,
|
||||
];
|
||||
}
|
||||
@@ -96,7 +96,7 @@ final class TreeEditor
|
||||
/** @param array{container: Container, parent: ?Block, index: int} $position */
|
||||
public function move(NodeIndex $index, Block $node, array $position): ?string
|
||||
{
|
||||
for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $this->parentBlock($index, $ancestor)) {
|
||||
for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $index->parentBlock($ancestor)) {
|
||||
if ($ancestor === $node) {
|
||||
return 'ein Knoten kann nicht in sich selbst verschoben werden';
|
||||
}
|
||||
@@ -139,13 +139,6 @@ final class TreeEditor
|
||||
return $copy;
|
||||
}
|
||||
|
||||
private function parentBlock(NodeIndex $index, Block $block): ?Block
|
||||
{
|
||||
$parent = $index->parentOf($block);
|
||||
|
||||
return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling S9: inserting at the document root of an empty page (no divi/placeholder yet)
|
||||
* first wraps it in one, the same way DiviContentComposer (Task 9) writes new content –
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||
|
||||
/**
|
||||
@@ -74,6 +75,17 @@ final class DiviChange
|
||||
return $this->changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path ID of an entry in the tree after the change: a removed node keeps its ID from before
|
||||
* the removal; a surviving node is looked up in $index (null if it is not there).
|
||||
*
|
||||
* @param array{block: ?Block, id: ?string, type: string, action: string, label: string} $entry
|
||||
*/
|
||||
public static function idOf(array $entry, NodeIndex $index): ?string
|
||||
{
|
||||
return $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null);
|
||||
}
|
||||
|
||||
/** @return list<Block> */
|
||||
public function touched(): array
|
||||
{
|
||||
|
||||
@@ -71,15 +71,14 @@ final class DiviContentComposer implements ContentComposer
|
||||
|
||||
$index = NodeIndex::build($layout['document']);
|
||||
$warnings = $composition->warnings()->collect($index, $layout['blocks']);
|
||||
$outline = new Outline($this->design->read()->presetNames());
|
||||
$text = $outline->text($outline->rows($index));
|
||||
if ($outline->wasTruncated()) {
|
||||
$outline = (new Outline($this->design->read()->presetNames()))->summary($index);
|
||||
if ($outline['truncated']) {
|
||||
$warnings[] = DiviWriter::OUTLINE_TRUNCATED;
|
||||
}
|
||||
|
||||
$saved = $this->saver->saveNew($postId, Serializer::document($layout['document']));
|
||||
|
||||
return ['warnings' => array_merge($warnings, $saved), 'outline' => $text];
|
||||
return ['warnings' => array_merge($warnings, $saved), 'outline' => (string) $outline['text']];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,7 +38,7 @@ final class DiviDeleteAbility extends DiviWriteAbility
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => array_merge(DiviWriter::inputProperties(), [
|
||||
'properties' => array_merge(DiviSchema::write(), [
|
||||
'ids' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
|
||||
@@ -34,7 +34,7 @@ final class DiviDuplicateNodeAbility extends DiviWriteAbility
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => array_merge(DiviWriter::inputProperties(), [
|
||||
'properties' => array_merge(DiviSchema::write(), [
|
||||
'id' => ['type' => 'string', 'description' => 'Pfad-ID des zu duplizierenden Knotens.'],
|
||||
]),
|
||||
'required' => ['post_id', 'tree_rev', 'id'],
|
||||
|
||||
@@ -39,7 +39,7 @@ final class DiviInsertAbility extends DiviWriteAbility
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => array_merge(DiviWriter::inputProperties(), [
|
||||
'properties' => array_merge(DiviSchema::write(), [
|
||||
'nodes' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
@@ -48,7 +48,7 @@ final class DiviInsertAbility extends DiviWriteAbility
|
||||
'description' => 'Einzufügende Knoten in Reihenfolge; Abschnitte mit rows/modules, Zeilen mit '
|
||||
. 'columns und modules, Module mit Kurzform-Feldern, items (Kind-Module) und attrs.',
|
||||
],
|
||||
], DiviWriter::positionProperties()),
|
||||
], DiviSchema::position()),
|
||||
'required' => ['post_id', 'tree_rev', 'nodes'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
@@ -68,7 +68,7 @@ final class DiviInsertAbility extends DiviWriteAbility
|
||||
return DiviErrors::position($position);
|
||||
}
|
||||
|
||||
$context = DiviWriter::sectionLabel($page->index, $position['parent']);
|
||||
$context = $page->index->sectionLabel($position['parent']);
|
||||
$nodes = is_array($input['nodes'] ?? null) ? array_values($input['nodes']) : [];
|
||||
if ($nodes === []) {
|
||||
return Errors::invalidInput('Keine Knoten angegeben; nichts wurde gespeichert.', [
|
||||
|
||||
@@ -35,9 +35,9 @@ final class DiviMoveAbility extends DiviWriteAbility
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => array_merge(
|
||||
DiviWriter::inputProperties(),
|
||||
DiviSchema::write(),
|
||||
['id' => ['type' => 'string', 'description' => 'Pfad-ID des zu verschiebenden Knotens.']],
|
||||
DiviWriter::positionProperties()
|
||||
DiviSchema::position()
|
||||
),
|
||||
'required' => ['post_id', 'tree_rev', 'id'],
|
||||
'additionalProperties' => false,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
/** Input schema properties the Divi write tools share. */
|
||||
final class DiviSchema
|
||||
{
|
||||
/** @return array<string, array<string, mixed>> post_id and tree_rev of every Divi write. */
|
||||
public static function write(): array
|
||||
{
|
||||
return [
|
||||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'tree_rev' => [
|
||||
'type' => 'string',
|
||||
'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> Target position of insert and move. */
|
||||
public static function position(): array
|
||||
{
|
||||
return [
|
||||
'parent' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Pfad-ID des Elternknotens (z. B. Spalte "s0.r0.c1"); ohne parent/before/after: '
|
||||
. 'Ende der Seite.',
|
||||
],
|
||||
'before' => ['type' => 'string', 'description' => 'Pfad-ID: vor diesem Knoten einfügen.'],
|
||||
'after' => ['type' => 'string', 'description' => 'Pfad-ID: nach diesem Knoten einfügen.'],
|
||||
'index' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Position unter parent (0 = zuerst).'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ final class DiviUpdateAbility extends DiviWriteAbility
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => array_merge(DiviWriter::inputProperties(), [
|
||||
'properties' => array_merge(DiviSchema::write(), [
|
||||
'changes' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
|
||||
@@ -9,9 +9,7 @@ use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||
@@ -40,33 +38,6 @@ class DiviWriter
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> post_id and tree_rev of every Divi write. */
|
||||
public static function inputProperties(): array
|
||||
{
|
||||
return [
|
||||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'tree_rev' => [
|
||||
'type' => 'string',
|
||||
'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> Target position of insert and move. */
|
||||
public static function positionProperties(): array
|
||||
{
|
||||
return [
|
||||
'parent' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Pfad-ID des Elternknotens (z. B. Spalte "s0.r0.c1"); ohne parent/before/after: '
|
||||
. 'Ende der Seite.',
|
||||
],
|
||||
'before' => ['type' => 'string', 'description' => 'Pfad-ID: vor diesem Knoten einfügen.'],
|
||||
'after' => ['type' => 'string', 'description' => 'Pfad-ID: nach diesem Knoten einfügen.'],
|
||||
'index' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Position unter parent (0 = zuerst).'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The page to change, after rights, type, layout and the tree_rev lock were checked.
|
||||
*
|
||||
@@ -104,18 +75,6 @@ class DiviWriter
|
||||
return $this->composition->forPost((int) $page->post->ID);
|
||||
}
|
||||
|
||||
/** Section label around $block, for automatic admin labels ("" when none). */
|
||||
public static function sectionLabel(NodeIndex $index, ?Block $block): string
|
||||
{
|
||||
for ($node = $block; $node !== null; $node = self::parentBlock($index, $node)) {
|
||||
if ($node->name() === 'divi/section') {
|
||||
return NodeSummary::label($node);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the changed tree of $page and returns the ToolResult array. Call it only when the
|
||||
* whole input validated – every error before means nothing is written.
|
||||
@@ -178,15 +137,16 @@ class DiviWriter
|
||||
->with('tree_rev', Rev::of((string) $updated->post_content))
|
||||
->urls((string) get_preview_post_link($updated), $page->editUrl());
|
||||
foreach ($change->entries() as $entry) {
|
||||
$id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null);
|
||||
$tool->changed($id ?? '?', $entry['type'], $entry['action'], $entry['label']);
|
||||
$tool->changed(DiviChange::idOf($entry, $index) ?? '?', $entry['type'], $entry['action'], $entry['label']);
|
||||
}
|
||||
|
||||
return $tool->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline of the sections that contain changed nodes (breadth limit of ruling S18).
|
||||
* Outline of the sections that contain changed nodes (breadth limit of ruling S18). A removed
|
||||
* node has no block any more, but its ID from before the removal still names the (surviving)
|
||||
* section.
|
||||
*
|
||||
* @return array{text: ?string, truncated: bool}
|
||||
*/
|
||||
@@ -194,9 +154,7 @@ class DiviWriter
|
||||
{
|
||||
$sections = [];
|
||||
foreach ($change->entries() as $entry) {
|
||||
// Same id resolution as the "changed" entries below: a removed node has no block
|
||||
// any more, but its id from before the removal still names the (surviving) section.
|
||||
$id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null);
|
||||
$id = DiviChange::idOf($entry, $index);
|
||||
if ($id !== null) {
|
||||
$sections[explode('.', $id)[0]] = true;
|
||||
}
|
||||
@@ -207,20 +165,7 @@ class DiviWriter
|
||||
}
|
||||
|
||||
$outline = new Outline($this->design->read()->presetNames());
|
||||
$texts = [];
|
||||
$truncated = false;
|
||||
foreach (array_keys($sections) as $sectionId) {
|
||||
$texts[] = $outline->text($outline->rows($index, (string) $sectionId));
|
||||
$truncated = $truncated || $outline->wasTruncated();
|
||||
}
|
||||
|
||||
return ['text' => implode("\n", $texts), 'truncated' => $truncated];
|
||||
}
|
||||
|
||||
private static function parentBlock(NodeIndex $index, Block $block): ?Block
|
||||
{
|
||||
$parent = $index->parentOf($block);
|
||||
|
||||
return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null;
|
||||
return $outline->summary($index, array_map('strval', array_keys($sections)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,13 @@ final class BlockTest extends TestCase
|
||||
self::assertSame('divi/button', Block::create('divi/button')->name());
|
||||
}
|
||||
|
||||
public function testOnlyThePlaceholderIsAPlaceholder(): void
|
||||
{
|
||||
self::assertTrue(Block::create('divi/placeholder')->isPlaceholder());
|
||||
self::assertFalse(Block::create('divi/section')->isPlaceholder());
|
||||
self::assertFalse(Block::create('core/placeholder')->isPlaceholder());
|
||||
}
|
||||
|
||||
public function testSetAttrsRefusesOnAnOpaqueGlobalModule(): void
|
||||
{
|
||||
$document = (new Parser())->parse('<!-- wp:divi/section {"globalModule":"x"} --><!-- /wp:divi/section -->');
|
||||
|
||||
@@ -45,6 +45,20 @@ final class NodeIndexTest extends TestCase
|
||||
self::assertSame(0, preg_match(NodeIndex::ID_PATTERN, "s1\n"));
|
||||
}
|
||||
|
||||
public function testParentBlockSkipsThePlaceholderAndSectionLabelWalksUp(): void
|
||||
{
|
||||
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
|
||||
$button = $index->find('s0.r0.c1.m2');
|
||||
|
||||
self::assertTrue($index->root() instanceof \LIT\MCP\Integrations\Divi\BlockTree\Block
|
||||
&& $index->root()->isPlaceholder());
|
||||
self::assertSame($index->find('s0.r0.c1'), $index->parentBlock($button));
|
||||
self::assertNull($index->parentBlock($index->find('s0')), 'the placeholder is no parent node');
|
||||
self::assertSame('Hero', $index->sectionLabel($button));
|
||||
self::assertSame('Hero', $index->sectionLabel($index->find('s0')));
|
||||
self::assertSame('', $index->sectionLabel(null));
|
||||
}
|
||||
|
||||
public function testDocumentWithoutPlaceholderIsItsOwnRoot(): void
|
||||
{
|
||||
$document = (new Parser())->parse('<!-- wp:divi/section /-->');
|
||||
|
||||
@@ -82,6 +82,22 @@ final class OutlineTest extends TestCase
|
||||
self::assertTrue($outline->wasTruncated());
|
||||
}
|
||||
|
||||
public function testSummaryOfThePageOrOfSections(): void
|
||||
{
|
||||
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
|
||||
$outline = new Outline();
|
||||
|
||||
self::assertSame(
|
||||
['text' => $outline->text($outline->rows($index)), 'truncated' => false],
|
||||
$outline->summary($index)
|
||||
);
|
||||
self::assertSame(
|
||||
$outline->text($outline->rows($index, 's0')) . "\n" . $outline->text($outline->rows($index, 's1')),
|
||||
$outline->summary($index, ['s0', 's1'])['text']
|
||||
);
|
||||
self::assertSame(['text' => null, 'truncated' => false], $outline->summary($index, []));
|
||||
}
|
||||
|
||||
public function testReadOnlyNodesAreMarked(): void
|
||||
{
|
||||
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html')));
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DiviChangeTest extends TestCase
|
||||
{
|
||||
public function testIdOfAnEntryAfterTheChange(): void
|
||||
{
|
||||
$index = NodeIndex::build((new Parser())->parse(
|
||||
'<!-- wp:divi/section --><!-- wp:divi/row /--><!-- /wp:divi/section -->'
|
||||
));
|
||||
$row = $index->find('s0.r0');
|
||||
$change = (new DiviChange('x'))
|
||||
->changed($row, 'updated')
|
||||
->removed('s3.r1', Block::create('divi/row'))
|
||||
->changed(Block::create('divi/text'), 'inserted');
|
||||
|
||||
self::assertSame(
|
||||
['s0.r0', 's3.r1', null],
|
||||
array_map(static fn (array $entry): ?string => DiviChange::idOf($entry, $index), $change->entries())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,8 @@ final class DiviWriterTest extends TestCase
|
||||
DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html'));
|
||||
$page = (new DiviPageLoader(DiviFixtures::schema()))->load(7, true);
|
||||
|
||||
self::assertSame('Hero', DiviWriter::sectionLabel($page->index, $page->index->find('s0.r0.c1')));
|
||||
self::assertSame('', DiviWriter::sectionLabel($page->index, null));
|
||||
self::assertSame('Hero', $page->index->sectionLabel($page->index->find('s0.r0.c1')));
|
||||
self::assertSame('', $page->index->sectionLabel(null));
|
||||
}
|
||||
|
||||
public function testCompositionIsBoundToThePage(): void
|
||||
|
||||
Reference in New Issue
Block a user