*/ private array $stored; private SnapshotStore $snapshots; protected function setUp(): void { parent::setUp(); $this->stored = PresetBookTest::stored(); $this->snapshots = Mockery::mock(SnapshotStore::class); Functions\when('current_user_can')->justReturn(true); // DesignLock (final review I-5): the lock is always free here. Functions\when('add_option')->justReturn(true); Functions\when('delete_option')->justReturn(true); } private function ability(bool $unfiltered = true, ?GlobalUsages $usages = null, ?DiviPageLoader $loader = null, ?DesignSystemWriter $writer = null): DiviPresetsSetAbility { $writer ??= $this->savingWriter(); $usages ??= Mockery::mock(GlobalUsages::class); $usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false)); return $this->build($writer, $usages, $loader, $unfiltered); } private function savingWriter(): DesignSystemWriter { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('presets')->andReturnUsing(fn (): array => $this->stored); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->allows('savePresets')->andReturnUsing(function (array $rest, bool $mayRemove): array { $data = ['module' => [], 'group' => []]; foreach ($rest as $kind => $records) { foreach ($records as $record) { $first = $record['items'][0] ?? null; $name = $kind === 'group' ? ($first['groupName'] ?? 'x') : ($first['moduleName'] ?? 'x'); $data[$kind][$name] = ['default' => $record['default'], 'items' => array_column($record['items'], null, 'id')]; } } return $this->stored = $data; }); return $writer; } private function build(DesignSystemWriter $writer, GlobalUsages $usages, ?DiviPageLoader $loader, bool $unfiltered): DiviPresetsSetAbility { $composer = static fn (): PresetComposer => new PresetComposer( ComposeFixture::composer($unfiltered), new StoredNodeGuard(ComposeFixture::cleaner($unfiltered), ComposeFixture::references(), ComposeFixture::sanitizer($unfiltered)), static fn (string $module, array $attrs): array => ['all' => array_map( static fn (mixed $v): mixed => is_array($v) ? array_diff_key($v, ['innerContent' => 1]) : $v, $attrs ), 'style' => [], 'render' => []], static fn (string $module): array => ['module.decoration.spacing' => 'divi/spacing'], '5.15.1', static fn (): int => 1 ); return new DiviPresetsSetAbility( $writer, new DesignChange($this->snapshots, new FakeChangeLog(5)), $usages, $loader ?? new DiviPageLoader(DiviFixtures::schema()), $composer ); } private static function newButton(array $extra = []): array { return ['items' => [$extra + ['module' => 'button', 'name' => 'CTA dunkel', 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#111111']]]]]]; } public function testDryRunThenConfirmCreatesThePreset(): void { $ability = $this->ability(); $dry = self::assertToolError($ability->execute(self::newButton()), 'confirm_required'); self::assertSame('added', $dry['diff'][0]['action']); self::assertStringContainsString('Presets: 1 angelegt.', $dry['message']); $this->snapshots->expects('save')->andReturn('presets-1'); $this->snapshots->expects('attachLog'); $result = $ability->execute(self::newButton() + ['confirm' => true, 'design_rev' => $dry['design_rev']]); self::assertTrue($result['ok']); self::assertSame('presets-1', $result['snapshot_id']); $names = array_column(PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button')), 'name'); self::assertContains('CTA dunkel', $names); } public function testExistingNameIsRefusedForANewPreset(): void { $result = self::assertToolError($this->ability()->execute(['items' => [ ['module' => 'button', 'name' => 'CTA primär'], ]]), 'validation_failed'); self::assertStringContainsString('aaaaaaaaaa', $result['fields']['items[0].name']); } public function testChangingAndMakingDefault(): void { $dry = self::assertToolError($this->ability()->execute(['items' => [[ 'module' => 'button', 'preset' => 'CTA primär', 'make_default' => true, 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#222222']]], ]]]), 'confirm_required'); self::assertSame(['changed', 'default_set'], array_column($dry['diff'], 'action')); } public function testUnknownPresetAndBadItemsAreCollected(): void { $result = self::assertToolError($this->ability()->execute(['items' => [ ['module' => 'button', 'preset' => 'Fehlt'], ['name' => 'ohne Modul'], ]]), 'validation_failed'); self::assertArrayHasKey('items[0].preset', $result['fields']); self::assertArrayHasKey('items[1]', $result['fields']); } /** Review Focus 1 (final review I-1): a catalog-known builder-only path, refused for editors only. */ public function testEditorWithoutUnfilteredHtmlIsRefusedForBuilderOnlyFields(): void { $input = ['items' => [[ 'module' => 'button', 'name' => 'Mit HTML', 'attrs' => ['module.advanced.html' => ['desktop' => ['elementType' => 'div']]], ]]]; $editor = self::assertToolError($this->ability(false)->execute($input), 'validation_failed'); self::assertArrayHasKey('items[0].attrs.module.advanced.html', $editor['fields']); self::assertStringContainsString('Divi-Builder', $editor['fields']['items[0].attrs.module.advanced.html']); self::assertStringContainsString('unfiltered_html', $editor['fields']['items[0].attrs.module.advanced.html']); self::assertToolError($this->ability(true)->execute($input), 'confirm_required'); } public function testFromNodeReadsThePage(): void { $content = '' . '' . ''; DiviFixtures::givenPost($content); $loader = new DiviPageLoader(DiviFixtures::schema()); $dry = self::assertToolError($this->ability(true, null, $loader)->execute(['items' => [[ 'module' => 'button', 'name' => 'Aus Seite', 'from_node' => ['post_id' => 7, 'id' => 's0.r0.c0.m0'], ]]]), 'confirm_required'); self::assertSame('added', $dry['diff'][0]['action']); } public function testNamesAreCleanedAndLimited(): void { $ability = $this->ability(); $dry = self::assertToolError($ability->execute(self::newButton(['name' => 'Fett Name'])), 'confirm_required'); $this->snapshots->expects('save')->andReturn('presets-1'); $this->snapshots->expects('attachLog'); $res = $ability->execute(self::newButton(['name' => 'Fett Name']) + ['confirm' => true, 'design_rev' => $dry['design_rev']]); self::assertTrue($res['ok']); $names = array_column(PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button')), 'name'); self::assertContains('Fett Name', $names); self::assertNotContains('Fett Name', $names); $long = self::assertToolError($this->ability()->execute(self::newButton(['name' => str_repeat('n', 101)])), 'validation_failed'); self::assertStringContainsString('1–100 Zeichen', $long['fields']['items[0].name']); $empty = self::assertToolError($this->ability()->execute(['items' => [ ['module' => 'button', 'preset' => 'CTA primär', 'rename' => ''], ]]), 'validation_failed'); self::assertStringContainsString('1–100 Zeichen', $empty['fields']['items[0].rename']); } public function testNameAndRenameAreExclusive(): void { $ability = $this->ability(); $new = self::assertToolError($ability->execute(['items' => [ ['module' => 'button', 'name' => 'Neu', 'rename' => 'x'], ]]), 'validation_failed'); self::assertStringContainsString('nur zusammen mit preset', $new['fields']['items[0].rename']); $change = self::assertToolError($ability->execute(['items' => [ ['module' => 'button', 'preset' => 'CTA primär', 'name' => 'CTA primär'], ]]), 'validation_failed'); self::assertStringContainsString('rename', $change['fields']['items[0].name']); } public function testRenameToAnotherPresetsNameIsRefused(): void { $result = self::assertToolError($this->ability()->execute(['items' => [ ['module' => 'button', 'preset' => 'CTA primär', 'rename' => 'Doppelt'], ]]), 'validation_failed'); self::assertStringContainsString('bbbbbbbbbb', $result['fields']['items[0].rename']); } public function testNothingIsWrittenWhenOneItemHasErrors(): void { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('presets')->andReturnUsing(fn (): array => $this->stored); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->shouldNotReceive('savePresets'); $this->snapshots->shouldNotReceive('save'); $ability = $this->ability(true, null, null, $writer); $result = $ability->execute(['items' => [ ['module' => 'button', 'name' => 'Gut', 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#333333']]]], ['module' => 'button', 'name' => 'Schlecht', 'attrs' => ['button.innerContent' => ['desktop' => ['text' => 'Hallo']]]], ], 'confirm' => true, 'design_rev' => 'egal']); self::assertContains(Errors::codeOf($result), ['validation_failed', 'invalid_attr_path']); self::assertNotEmpty(array_filter( array_keys(Errors::fieldsOf($result)), static fn (string $key): bool => str_starts_with($key, 'items[1]') )); } public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void { Functions\when('current_user_can')->justReturn(false); self::assertToolError($this->ability()->permission(self::newButton()), 'forbidden'); } public function testSchemaAndMeta(): void { $ability = $this->ability(); self::assertSame('lit/divi-presets-set', $ability->name()); self::assertFalse($ability->isDirect()); self::assertSame(DiviPresetsSetAbility::MAX_ITEMS, $ability->inputSchema()['properties']['items']['maxItems']); self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); } }