diff --git a/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php b/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php new file mode 100644 index 0000000..a3a7a1c --- /dev/null +++ b/src/Integrations/Divi/Design/DiviPresetsAssignAbility.php @@ -0,0 +1,332 @@ + 'object', + 'properties' => [ + 'module' => ['type' => 'string', 'description' => 'Modul, z. B. "button" oder "divi/button".'], + 'preset' => [ + 'type' => ['string', 'array'], + 'items' => ['type' => 'string'], + 'minItems' => 1, + 'maxItems' => self::MAX_STACK, + 'description' => 'Modul-Preset (Name oder ID) oder Stapel; bei gleicher Priorität gewinnt das ' + . 'letzte Preset im Stapel (Divi-Verhalten).', + ], + 'group_presets' => [ + 'type' => 'object', + 'description' => '{"": "" | ["", …]}; Gruppe als Kurzname (font-body), voller ' + . 'Name (divi/font-body) oder Attributpfad.', + ], + 'post_ids' => PostIdsInput::schema(), + 'only_without_preset' => [ + 'type' => 'boolean', + 'description' => 'Nur Module ohne eigenes Modul-Preset.', + ], + 'only_with_preset' => [ + 'type' => 'string', + 'description' => 'Nur Module mit diesem Modul-Preset (Name oder ID); nicht zusammen mit ' + . 'only_without_preset.', + ], + 'clear_overrides' => [ + 'type' => 'boolean', + 'description' => 'Lokale Werte entfernen, die die zugewiesenen Presets überdecken.', + ], + ] + array_intersect_key(DesignChange::inputProperties(), ['confirm' => true]), + 'required' => ['module'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return PageRewriteReport::outputSchema(); + } + + public function annotations(): array + { + return Annotations::write(true); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return DesignRights::presets($this->writer) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = DesignRights::presets($this->writer); + if ($refusal !== null) { + return $refusal; + } + $request = $this->request($input); + if ($request instanceof WP_Error) { + return $request; + } + + $query = new ModuleUsageQuery($request['module']); + $change = 'Zuweisung an ' . $request['module']; + if (($input['confirm'] ?? false) !== true) { + return $this->dryRun($query, $request, $change); + } + + $result = $this->pages->apply($this->name(), $query, $request['rewrite'], $request['post_ids'], $change); + if ($result instanceof WP_Error) { + return $result; + } + + return ToolResult::ok(PageRewriteReport::savedSummary($result, $change)) + ->warnings($result['warnings']) + ->with('saved', $result['saved']) + ->with('remaining', $result['remaining']) + ->with('skipped', $result['skipped']) + ->toArray(); + } + + /** + * @param array{module: string, rewrite: Closure(): NodeRewrite, post_ids: list|null} $request + * @return array|WP_Error + */ + private function dryRun(ModuleUsageQuery $query, array $request, string $change): array|WP_Error + { + $preview = $this->pages->preview($query, $request['rewrite'], $request['post_ids']); + if ($preview instanceof WP_Error) { + return $preview; + } + if (PageRewriteReport::isEmpty($preview)) { + return PageRewriteReport::nothingToDo($change . ' – keine Seite betroffen, nichts zu tun.', $preview); + } + + return Errors::confirmRequired(PageRewriteReport::dryRunMessage($preview, $change), $preview); + } + + /** + * The checked call: full module name, the rewrite strategy per page and the post filter. + * + * @param array $input + * @return array{module: string, rewrite: Closure(): NodeRewrite, post_ids: list|null}|WP_Error + */ + private function request(array $input): array|WP_Error + { + $errors = []; + $module = self::module($input['module'] ?? null, $errors); + $spec = self::spec($input, $errors); + $postIds = self::postIds($input['post_ids'] ?? null, $errors); + $onlyWithout = self::flag($input, 'only_without_preset', $errors); + $clear = self::flag($input, 'clear_overrides', $errors); + if ($module === null || $errors !== []) { + return self::invalid($errors); + } + + $assignment = ($this->assignment)(); + foreach ($assignment->apply($module, [], $spec, self::ROOT)['errors'] as $path => $problem) { + $errors[self::field($path)] = $problem; + } + $onlyWith = $this->onlyWith($module, $input, $onlyWithout, $errors); + if ($errors !== []) { + return self::invalid($errors); + } + + return [ + 'module' => $module, + 'post_ids' => $postIds, + 'rewrite' => static fn (): NodeRewrite => new AssignRewrite( + $module, + $assignment, + $spec, + $onlyWithout, + $onlyWith, + $clear + ), + ]; + } + + /** @param array $errors */ + private static function module(mixed $value, array &$errors): ?string + { + try { + return PresetScope::fromInput([PresetScope::MODULE => $value], 'module')->name; + } catch (InvalidArgumentException) { + $errors['module'] = 'Modulname wie "button" oder "divi/button" erwartet'; + + return null; + } + } + + /** + * preset and group_presets as given; null removes nothing site-wide (lit/divi-design-detach does). + * + * @param array $input + * @param array $errors + * @return array + */ + private static function spec(array $input, array &$errors): array + { + $spec = array_intersect_key($input, ['preset' => true, 'group_presets' => true]); + if ($spec === []) { + $errors['preset'] = 'preset oder group_presets angeben'; + } + if (array_key_exists('preset', $spec) && $spec['preset'] === null) { + $errors['preset'] = self::USE_DETACH; + } + $groups = $spec['group_presets'] ?? null; + if ($groups === []) { + $errors['group_presets'] = 'mindestens eine Gruppe angeben'; + } + foreach (is_array($groups) ? $groups : [] as $key => $value) { + if ($value === null) { + $errors['group_presets.' . $key] = self::USE_DETACH; + } + } + + return $spec; + } + + /** + * @param array $errors + * @return list|null + */ + private static function postIds(mixed $value, array &$errors): ?array + { + try { + return PostIdsInput::parse($value); + } catch (InvalidArgumentException $e) { + $errors['post_ids'] = $e->getMessage(); + + return null; + } + } + + /** + * @param array $input + * @param array $errors + */ + private static function flag(array $input, string $key, array &$errors): bool + { + $value = $input[$key] ?? false; + if (! is_bool($value)) { + $errors[$key] = 'true oder false erwartet'; + + return false; + } + + return $value; + } + + /** + * The ID of the module preset only_with_preset names; null without that filter or after noting a problem. + * + * @param array $input + * @param array $errors + */ + private function onlyWith(string $module, array $input, bool $onlyWithout, array &$errors): ?string + { + $value = $input['only_with_preset'] ?? null; + if ($value === null) { + return null; + } + if ($onlyWithout) { + $errors['only_with_preset'] = 'nicht zusammen mit only_without_preset'; + + return null; + } + + $scope = new PresetScope(PresetScope::MODULE, $module); + $name = is_string($value) ? $value : ''; + try { + $found = PresetBook::fromDivi($this->writer->presets())->find($scope, $name); + } catch (InvalidArgumentException $e) { + $errors['only_with_preset'] = $e->getMessage(); + + return null; + } + if ($found === null) { + $errors['only_with_preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), $name); + + return null; + } + + return (string) $found['id']; + } + + private static function field(string $path): string + { + $prefix = self::ROOT . '.'; + + return str_starts_with($path, $prefix) ? substr($path, strlen($prefix)) : $path; + } + + /** @param array $errors */ + private static function invalid(array $errors): WP_Error + { + return Errors::invalidInput('Ungültige Angaben; nichts wurde geändert.', $errors); + } +} diff --git a/src/Integrations/Divi/Design/PageRewriteReport.php b/src/Integrations/Divi/Design/PageRewriteReport.php new file mode 100644 index 0000000..c302370 --- /dev/null +++ b/src/Integrations/Divi/Design/PageRewriteReport.php @@ -0,0 +1,110 @@ + $preview */ + public static function isEmpty(array $preview): bool + { + return $preview['pages_count'] === 0 && $preview['blocked_ids'] === [] && $preview['hidden_count'] === 0; + } + + /** @param array $preview */ + public static function dryRunMessage(array $preview, string $change): string + { + $message = sprintf( + '%s – betroffen (Seiten: %d, Module: %d). Nichts wurde gespeichert (Probelauf). Nach Rückfrage beim ' + . 'Nutzer mit confirm: true erneut aufrufen; je Aufruf werden höchstens %d Seiten geschrieben.', + $change, + $preview['pages_count'], + $preview['nodes_count'], + PageRewriter::PAGE_LIMIT + ); + if ($preview['blocked_ids'] !== []) { + $message .= ' Seiten unter blocked_ids darf dieses Konto nicht bearbeiten; mit confirm würde nichts ' + . 'gespeichert.'; + } + + return $message; + } + + /** @param array $result */ + public static function savedSummary(array $result, string $change): string + { + $summary = sprintf( + '%s – gespeichert (Seiten: %d, Module: %d).', + $change, + count($result['saved']), + array_sum(array_column($result['saved'], 'nodes')) + ); + if ($result['remaining'] > 0) { + return $summary . sprintf( + ' Noch %d Seiten offen: mit denselben Angaben erneut mit confirm: true aufrufen.', + $result['remaining'] + ); + } + if ($result['skipped'] !== []) { + return $summary . sprintf(' Übersprungen: %d (siehe skipped).', count($result['skipped'])); + } + + return $summary . ' Alle Fundstellen sind erledigt.'; + } + + /** + * @param array $preview + * @param list $warnings + * @return array + */ + public static function nothingToDo(string $summary, array $preview, array $warnings = []): array + { + return ToolResult::ok($summary) + ->warnings($warnings) + ->with('saved', []) + ->with('remaining', 0) + ->with('skipped', $preview['skipped']) + ->toArray(); + } + + /** + * @param array> $extra Further optional output properties. + * @return array + */ + public static function outputSchema(array $extra = []): array + { + return ToolResult::schema([ + 'saved' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer'], + 'revision_id' => ['type' => ['integer', 'null']], + 'nodes' => ['type' => 'integer'], + ], + 'required' => ['post_id', 'revision_id', 'nodes'], + ], + ], + 'remaining' => ['type' => 'integer'], + 'skipped' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => ['post_id' => ['type' => 'integer'], 'reason' => ['type' => 'string']], + 'required' => ['post_id', 'reason'], + ], + ], + ] + $extra, ['saved', 'remaining', 'skipped']); + } +} diff --git a/src/Integrations/Divi/Design/PostIdsInput.php b/src/Integrations/Divi/Design/PostIdsInput.php new file mode 100644 index 0000000..961198a --- /dev/null +++ b/src/Integrations/Divi/Design/PostIdsInput.php @@ -0,0 +1,59 @@ + */ + public static function schema(): array + { + return [ + 'type' => 'array', + 'items' => ['type' => 'integer', 'minimum' => 1], + 'minItems' => 1, + 'maxItems' => self::MAX, + 'description' => 'Nur diese Beiträge (IDs); ohne Angabe alle Fundstellen.', + ]; + } + + /** + * @return list|null null without a filter; duplicates removed. + * @throws InvalidArgumentException For anything but a list of 1 to MAX positive integers. + */ + public static function parse(mixed $value): ?array + { + if ($value === null) { + return null; + } + if (! is_array($value) || ! array_is_list($value) || $value === [] || count($value) > self::MAX) { + throw self::invalid(); + } + foreach ($value as $id) { + if (! is_int($id) || $id < 1) { + throw self::invalid(); + } + } + + return array_values(array_unique($value)); + } + + private static function invalid(): InvalidArgumentException + { + return new InvalidArgumentException(sprintf( + 'Liste von 1 bis %d Beitrags-IDs (positive ganze Zahlen) erwartet', + self::MAX + )); + } +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 0ff12e3..b3c9c8c 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -15,9 +15,11 @@ use LIT\MCP\Integrations\Divi\Design\DesignRights; use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility; +use LIT\MCP\Integrations\Divi\Design\DiviPresetsAssignAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility; use LIT\MCP\Integrations\Divi\Design\PageRewriter; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; use LIT\MCP\Integrations\Divi\Design\PresetComposer; use LIT\MCP\Integrations\Divi\Design\SnapshotStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; @@ -185,6 +187,11 @@ final class DiviIntegration implements Integration new SnapshotStore(), DesignRights::unfilteredHtml() ), + new DiviPresetsAssignAbility( + $this->designWriter, + $this->pageRewriter($loader), + $this->presetAssignment(...) + ), ]; } @@ -284,6 +291,12 @@ final class DiviIntegration implements Integration ); } + /** A fresh PresetAssignment (own preset book) per lit/divi-presets-assign call, wired like the Composer's (D7). */ + private function presetAssignment(): PresetAssignment + { + return $this->composition()->presets(); + } + private function loader(): DiviPageLoader { return new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess); diff --git a/tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php new file mode 100644 index 0000000..a85d187 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviPresetsAssignAbilityTest.php @@ -0,0 +1,220 @@ +justReturn(true); + } + + private function ability(PageRewriter $pages): DiviPresetsAssignAbility + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->allows('canUseVisualBuilder')->andReturn(true); + + return new DiviPresetsAssignAbility( + $writer, + $pages, + static fn (): PresetAssignment => new PresetAssignment( + static fn (): PresetBook => PresetBook::fromDivi(PresetBookTest::stored()), + static fn (string $module): array => $module === 'divi/text' + ? ['content.decoration.bodyFont' => 'divi/font-body'] + : [], + static fn (string $id): bool => preg_match('/^[0-9a-z]{10}$/', $id) === 1 + ) + ); + } + + /** A PageRewriter that must not be asked at all. */ + private static function untouched(): PageRewriter&MockInterface + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview')->never(); + $pages->expects('apply')->never(); + + return $pages; + } + + private static function moduleOf(ModuleUsageQuery $query): string + { + return (new ReflectionProperty(ModuleUsageQuery::class, 'module'))->getValue($query); + } + + private static function onlyWithOf(AssignRewrite $rewrite): ?string + { + return (new ReflectionProperty(AssignRewrite::class, 'onlyWithPresetId'))->getValue($rewrite); + } + + public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void + { + $ability = $this->ability(self::untouched()); + Functions\when('current_user_can')->justReturn(false); + $input = ['module' => 'button', 'preset' => 'CTA primär']; + + self::assertToolError($ability->permission($input), 'forbidden'); + self::assertToolError($ability->execute($input), 'forbidden'); + } + + public function testUnknownPresetIsRefusedBeforeAnyPageIsRead(): void + { + $result = $this->ability(self::untouched())->execute(['module' => 'button', 'preset' => 'Fehlt']); + + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('preset', self::errorFields($result)); + } + + public function testOnlyWithAndOnlyWithoutPresetExcludeEachOther(): void + { + $result = $this->ability(self::untouched())->execute([ + 'module' => 'button', + 'preset' => 'CTA primär', + 'only_without_preset' => true, + 'only_with_preset' => 'aaaaaaaaaa', + ]); + + self::assertToolError($result, 'validation_failed'); + self::assertSame( + ['only_with_preset' => 'nicht zusammen mit only_without_preset'], + self::errorFields($result) + ); + } + + public function testNullAndMissingAssignmentPointToDetach(): void + { + $ability = $this->ability(self::untouched()); + + $null = $ability->execute(['module' => 'button', 'preset' => null]); + self::assertToolError($null, 'validation_failed'); + self::assertStringContainsString('lit/divi-design-detach', self::errorFields($null)['preset']); + + $groupNull = $ability->execute(['module' => 'text', 'group_presets' => ['font-body' => null]]); + self::assertStringContainsString( + 'lit/divi-design-detach', + self::errorFields($groupNull)['group_presets.font-body'] + ); + + $missing = $ability->execute(['module' => 'button']); + self::assertSame('preset oder group_presets angeben', self::errorFields($missing)['preset']); + } + + public function testBadModuleFiltersAndPostIdsAreCollected(): void + { + $ability = $this->ability(self::untouched()); + + $fields = self::errorFields($ability->execute([ + 'module' => 'Button!', + 'preset' => 'CTA primär', + 'post_ids' => [0], + 'clear_overrides' => 'ja', + ])); + self::assertSame(['module', 'post_ids', 'clear_overrides'], array_keys($fields)); + + $unknown = self::errorFields($ability->execute([ + 'module' => 'button', + 'preset' => 'CTA primär', + 'only_with_preset' => 'Fehlt', + ])); + self::assertSame('unbekanntes Modul-Preset divi/button „Fehlt“', $unknown['only_with_preset']); + } + + public function testDryRunListsThePagesAndWritesNothing(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview') + ->with( + Mockery::on(static fn (ModuleUsageQuery $query): bool => self::moduleOf($query) === 'divi/button'), + Mockery::on(static fn (Closure $rewrite): bool => $rewrite() instanceof AssignRewrite), + null + ) + ->andReturn(PageRewriteReportTest::preview(2)); + $pages->expects('apply')->never(); + + $dry = self::assertToolError( + $this->ability($pages)->execute(['module' => 'button', 'preset' => ['CTA primär', 'cccccccccc']]), + 'confirm_required' + ); + + self::assertSame([12, 13], array_column($dry['pages'], 'post_id')); + self::assertSame(2, $dry['pages_count']); + self::assertStringStartsWith('Zuweisung an divi/button – betroffen (Seiten: 2, Module: 2).', $dry['message']); + self::assertStringContainsString('confirm: true', $dry['message']); + } + + public function testEmptyDryRunIsNothingToDo(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0)); + $pages->expects('apply')->never(); + + $result = $this->ability($pages)->execute(['module' => 'button', 'preset' => 'CTA primär']); + + self::assertTrue($result['ok']); + self::assertSame('Zuweisung an divi/button – keine Seite betroffen, nichts zu tun.', $result['summary']); + self::assertSame([[], 0], [$result['saved'], $result['remaining']]); + } + + public function testConfirmWritesAndReportsWhatRemains(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview')->never(); + $pages->expects('apply') + ->with( + 'lit/divi-presets-assign', + Mockery::on(static fn (ModuleUsageQuery $query): bool => self::moduleOf($query) === 'divi/button'), + Mockery::on(static fn (Closure $rewrite): bool => self::onlyWithOf($rewrite()) === 'aaaaaaaaaa'), + [12, 13], + 'Zuweisung an divi/button' + ) + ->andReturn(PageRewriteReportTest::applied(2, 3)); + + $result = $this->ability($pages)->execute([ + 'module' => 'divi/button', + 'preset' => ['bbbbbbbbbb', 'cccccccccc'], + 'only_with_preset' => 'CTA primär', + 'post_ids' => [12, 13], + 'confirm' => true, + ]); + + self::assertTrue($result['ok']); + self::assertSame(3, $result['remaining']); + self::assertSame([900, 901], array_column($result['saved'], 'revision_id')); + self::assertStringContainsString('Noch 3 Seiten offen', $result['summary']); + self::assertStringContainsString('confirm: true', $result['summary']); + } + + public function testSchemaAndMeta(): void + { + $ability = $this->ability(self::untouched()); + $schema = $ability->inputSchema(); + + self::assertSame('lit/divi-presets-assign', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => true], $ability->annotations()); + self::assertSame(['module'], $schema['required']); + self::assertFalse($schema['additionalProperties']); + self::assertSame(DiviPresetsAssignAbility::MAX_STACK, $schema['properties']['preset']['maxItems']); + self::assertArrayNotHasKey('design_rev', $schema['properties']); + self::assertSame(['saved', 'remaining', 'skipped'], array_slice($ability->outputSchema()['required'], -3)); + self::assertStringContainsString('gewinnt das letzte Preset im Stapel', $ability->description()); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php b/tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php new file mode 100644 index 0000000..4cc6ebe --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/PageRewriteReportTest.php @@ -0,0 +1,116 @@ + $blocked + * @return array + */ + public static function preview(int $pages, array $blocked = []): array + { + $listed = []; + for ($i = 0; $i < $pages; ++$i) { + $listed[] = [ + 'post_id' => 12 + $i, + 'title' => 'Seite ' . ($i + 1), + 'type' => 'page', + 'status' => 'publish', + 'nodes' => [['id' => 's0.r0.c0.m0', 'module' => 'divi/button', 'label' => 'Button']], + 'warnings' => [], + ]; + } + + return [ + 'pages' => $listed, + 'pages_count' => $pages, + 'nodes_count' => $pages, + 'skipped' => [], + 'blocked_ids' => $blocked, + 'hidden_count' => 0, + ]; + } + + /** + * A PageRewriter::apply() result: $saved pages (IDs from 12) with two modules each. + * + * @param list $skipped + * @return array + */ + public static function applied(int $saved, int $remaining, array $skipped = []): array + { + $rows = []; + for ($i = 0; $i < $saved; ++$i) { + $rows[] = ['post_id' => 12 + $i, 'revision_id' => 900 + $i, 'nodes' => 2]; + } + + return ['saved' => $rows, 'remaining' => $remaining, 'warnings' => [], 'skipped' => $skipped]; + } + + public function testEmptyMeansNothingToWriteNothingBlockedNothingHidden(): void + { + self::assertTrue(PageRewriteReport::isEmpty(self::preview(0))); + self::assertFalse(PageRewriteReport::isEmpty(self::preview(1))); + self::assertFalse(PageRewriteReport::isEmpty(self::preview(0, [7]))); + self::assertFalse(PageRewriteReport::isEmpty(['hidden_count' => 1] + self::preview(0))); + } + + public function testDryRunMessageNamesCountsTheLimitAndBlockedPages(): void + { + $message = PageRewriteReport::dryRunMessage(self::preview(2), 'Zuweisung an divi/button'); + + self::assertStringStartsWith('Zuweisung an divi/button – betroffen (Seiten: 2, Module: 2).', $message); + self::assertStringContainsString('Nichts wurde gespeichert (Probelauf).', $message); + self::assertStringContainsString('confirm: true', $message); + self::assertStringContainsString('höchstens 50 Seiten', $message); + self::assertStringNotContainsString('blocked_ids', $message); + self::assertStringContainsString('blocked_ids', PageRewriteReport::dryRunMessage(self::preview(1, [7]), 'X')); + } + + public function testSavedSummaryAsksForAnotherCallWhilePagesRemain(): void + { + self::assertSame( + 'X – gespeichert (Seiten: 2, Module: 4). Noch 3 Seiten offen: mit denselben Angaben erneut mit ' + . 'confirm: true aufrufen.', + PageRewriteReport::savedSummary(self::applied(2, 3), 'X') + ); + self::assertSame( + 'X – gespeichert (Seiten: 1, Module: 2). Alle Fundstellen sind erledigt.', + PageRewriteReport::savedSummary(self::applied(1, 0), 'X') + ); + self::assertSame( + 'X – gespeichert (Seiten: 0, Module: 0). Übersprungen: 1 (siehe skipped).', + PageRewriteReport::savedSummary(self::applied(0, 0, [['post_id' => 5, 'reason' => 'global']]), 'X') + ); + } + + public function testNothingToDoIsAToolResultWithTheSkippedPages(): void + { + $preview = ['skipped' => [['post_id' => 5, 'reason' => 'global']]] + self::preview(0); + + $result = PageRewriteReport::nothingToDo('Nichts zu tun.', $preview, ['Hinweis']); + + self::assertTrue($result['ok']); + self::assertSame('Nichts zu tun.', $result['summary']); + self::assertSame(['Hinweis'], $result['warnings']); + self::assertSame([[], 0], [$result['saved'], $result['remaining']]); + self::assertSame(5, $result['skipped'][0]['post_id']); + } + + public function testOutputSchemaRequiresTheRewriteKeys(): void + { + $schema = PageRewriteReport::outputSchema(['diff' => ['type' => 'array']]); + + self::assertSame(['saved', 'remaining', 'skipped'], array_slice($schema['required'], -3)); + self::assertSame('integer', $schema['properties']['remaining']['type']); + self::assertSame(['type' => 'array'], $schema['properties']['diff']); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PostIdsInputTest.php b/tests/Unit/Integrations/Divi/Design/PostIdsInputTest.php new file mode 100644 index 0000000..5abdd87 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/PostIdsInputTest.php @@ -0,0 +1,53 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Liste von 1 bis 50 Beitrags-IDs (positive ganze Zahlen) erwartet'); + + PostIdsInput::parse($value); + } + + /** @return array */ + public static function invalidInput(): array + { + return [ + 'leer' => [[]], + 'null als ID' => [[0]], + 'negativ' => [[-3]], + 'Text' => [['12']], + 'Objekt' => [['a' => 1]], + 'zu viele' => [range(1, 51)], + 'keine Liste' => [12], + ]; + } + + public function testSchemaMatchesTheParser(): void + { + $schema = PostIdsInput::schema(); + + self::assertSame('array', $schema['type']); + self::assertSame(1, $schema['minItems']); + self::assertSame(50, $schema['maxItems']); + self::assertSame(['type' => 'integer', 'minimum' => 1], $schema['items']); + } +} diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index e74d926..d5bea0b 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -36,6 +36,7 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-presets-delete', 'lit/divi-design-snapshots', 'lit/divi-design-restore', + 'lit/divi-presets-assign', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void