From 47040aa43d3ff1f721615d151fc3226adbdc6fda Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 02:21:09 +0200 Subject: [PATCH] feat: admin check steps with throwaway write test and reports Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Release/CheckReport.php | 105 +++++++++ .../Divi/Release/CheckReportStore.php | 23 ++ src/Integrations/Divi/Release/CheckStep.php | 62 +++++ .../Divi/Release/CompatibilityCheck.php | 92 ++++++++ src/Integrations/Divi/Release/FormatCheck.php | 79 +++++++ src/Integrations/Divi/Release/WriteProbe.php | 218 ++++++++++++++++++ .../Divi/Release/reference-page.html | 1 + .../Divi/Schema/ShortformCheck.php | 51 ++++ tests/Integration/divi-shortform-check.php | 28 +-- .../Divi/Release/CheckReportTest.php | 61 +++++ .../Divi/Release/CompatibilityCheckTest.php | 92 ++++++++ .../Divi/Release/FormatCheckTest.php | 50 ++++ .../Divi/Release/WriteProbeTest.php | 159 +++++++++++++ .../Divi/Schema/ShortformCheckTest.php | 31 +++ 14 files changed, 1026 insertions(+), 26 deletions(-) create mode 100644 src/Integrations/Divi/Release/CheckReport.php create mode 100644 src/Integrations/Divi/Release/CheckReportStore.php create mode 100644 src/Integrations/Divi/Release/CheckStep.php create mode 100644 src/Integrations/Divi/Release/CompatibilityCheck.php create mode 100644 src/Integrations/Divi/Release/FormatCheck.php create mode 100644 src/Integrations/Divi/Release/WriteProbe.php create mode 100644 src/Integrations/Divi/Release/reference-page.html create mode 100644 src/Integrations/Divi/Schema/ShortformCheck.php create mode 100644 tests/Unit/Integrations/Divi/Release/CheckReportTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/FormatCheckTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/WriteProbeTest.php create mode 100644 tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php diff --git a/src/Integrations/Divi/Release/CheckReport.php b/src/Integrations/Divi/Release/CheckReport.php new file mode 100644 index 0000000..990c048 --- /dev/null +++ b/src/Integrations/Divi/Release/CheckReport.php @@ -0,0 +1,105 @@ + $steps */ + public function __construct( + public readonly string $id, + public readonly string $createdAt, + public readonly int $userId, + public readonly string $diviVersion, + public readonly string $pluginVersion, + public readonly string $stateKey, + public readonly array $steps + ) { + } + + public function green(): bool + { + if ($this->steps === []) { + return false; + } + foreach ($this->steps as $step) { + if (! $step->green()) { + return false; + } + } + + return true; + } + + /** Green and made for exactly this Divi version, plugin version and Divi files (ReleaseResolver::stateKey()). */ + public function freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool + { + return $this->green() + && $this->diviVersion === $diviVersion + && $this->pluginVersion === $pluginVersion + && $this->stateKey === $stateKey; + } + + public function step(string $key): ?CheckStep + { + foreach ($this->steps as $step) { + if ($step->key === $key) { + return $step; + } + } + + return null; + } + + /** @return array */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'created_at' => $this->createdAt, + 'user_id' => $this->userId, + 'divi_version' => $this->diviVersion, + 'plugin_version' => $this->pluginVersion, + 'state_key' => $this->stateKey, + 'steps' => array_map(static fn (CheckStep $step): array => $step->toArray(), $this->steps), + ]; + } + + public static function fromArray(mixed $data): ?self + { + if (! is_array($data)) { + return null; + } + foreach (['id', 'created_at', 'divi_version', 'plugin_version', 'state_key'] as $key) { + if (! is_string($data[$key] ?? null)) { + return null; + } + } + if (! is_int($data['user_id'] ?? null) || ! is_array($data['steps'] ?? null)) { + return null; + } + + $steps = []; + foreach ($data['steps'] as $step) { + $parsed = CheckStep::fromArray($step); + if ($parsed === null) { + return null; + } + $steps[] = $parsed; + } + + return new self( + $data['id'], + $data['created_at'], + $data['user_id'], + $data['divi_version'], + $data['plugin_version'], + $data['state_key'], + $steps + ); + } +} diff --git a/src/Integrations/Divi/Release/CheckReportStore.php b/src/Integrations/Divi/Release/CheckReportStore.php new file mode 100644 index 0000000..1141884 --- /dev/null +++ b/src/Integrations/Divi/Release/CheckReportStore.php @@ -0,0 +1,23 @@ +toArray(), false); + } +} diff --git a/src/Integrations/Divi/Release/CheckStep.php b/src/Integrations/Divi/Release/CheckStep.php new file mode 100644 index 0000000..fffac3d --- /dev/null +++ b/src/Integrations/Divi/Release/CheckStep.php @@ -0,0 +1,62 @@ + 'Divi-Funktionen, Klassen und Registrierungen', + self::MIRRORS => 'Fingerabdrücke nachgebauter Divi-Funktionen', + self::CATALOG => 'Modulkatalog (Kurzform-Pfade)', + self::FORMAT => 'Format im Speicher (Parser und Serializer)', + self::WRITE => 'Schreibtest auf einer Wegwerfseite', + ]; + + /** @param list $problems */ + public function __construct( + public readonly string $key, + public readonly array $problems, + public readonly bool $skipped = false + ) { + } + + public function green(): bool + { + return ! $this->skipped && $this->problems === []; + } + + public function label(): string + { + return self::LABELS[$this->key] ?? $this->key; + } + + /** @return array{key: string, problems: list, skipped: bool} */ + public function toArray(): array + { + return ['key' => $this->key, 'problems' => $this->problems, 'skipped' => $this->skipped]; + } + + public static function fromArray(mixed $data): ?self + { + if (! is_array($data) || ! is_string($data['key'] ?? null) || ! is_array($data['problems'] ?? null)) { + return null; + } + + return new self( + $data['key'], + array_values(array_filter($data['problems'], 'is_string')), + ($data['skipped'] ?? false) === true + ); + } +} diff --git a/src/Integrations/Divi/Release/CompatibilityCheck.php b/src/Integrations/Divi/Release/CompatibilityCheck.php new file mode 100644 index 0000000..3ed50c4 --- /dev/null +++ b/src/Integrations/Divi/Release/CompatibilityCheck.php @@ -0,0 +1,92 @@ + $catalog Short-form problems against Divi's live catalog. + * @param Closure(): string $version Installed Divi version ('' = unknown). + * @param Closure(string): string $stateKey ReleaseResolver::stateKey(). + */ + public function __construct( + private ManifestCheck $manifests, + private Closure $catalog, + private FormatCheck $format, + private WriteProbe $probe, + private Closure $version, + private Closure $stateKey + ) { + } + + public static function create(ContentComposer $composer): self + { + return new self( + new ManifestCheck(), + static fn (): array => DiviModules::isAvailable() + ? ShortformCheck::problems((new DiviModules())->catalog()) + : ['Divi 5 ist in dieser Anfrage nicht geladen.'], + new FormatCheck(), + new WriteProbe($composer), + static fn (): string => DiviEnvironment::version() ?? '', + static fn (string $version): string => ReleaseResolver::stateKey($version) + ); + } + + public function run(int $userId): CheckReport + { + $manifest = self::guarded(fn (): array => ManifestCheck::failed( + array_merge($this->manifests->codeChecks(), $this->manifests->registrationChecks()) + )); + $steps = [ + new CheckStep(CheckStep::MANIFEST, $manifest), + new CheckStep(CheckStep::MIRRORS, self::guarded( + fn (): array => ManifestCheck::failed($this->manifests->mirrorChecks()) + )), + new CheckStep(CheckStep::CATALOG, self::guarded($this->catalog)), + new CheckStep(CheckStep::FORMAT, self::guarded(fn (): array => $this->format->problems())), + $manifest === [] + ? new CheckStep(CheckStep::WRITE, self::guarded(fn (): array => $this->probe->run())) + : new CheckStep(CheckStep::WRITE, [], true), + ]; + $version = ($this->version)(); + + return new CheckReport( + wp_generate_uuid4(), + gmdate('c'), + $userId, + $version, + Plugin::VERSION, + ($this->stateKey)($version), + $steps + ); + } + + /** @return list */ + private static function guarded(Closure $step): array + { + try { + return array_values(array_filter((array) $step(), 'is_string')); + } catch (Throwable $e) { + return ['Prüfschritt abgebrochen: ' . $e->getMessage()]; + } + } +} diff --git a/src/Integrations/Divi/Release/FormatCheck.php b/src/Integrations/Divi/Release/FormatCheck.php new file mode 100644 index 0000000..0b21f43 --- /dev/null +++ b/src/Integrations/Divi/Release/FormatCheck.php @@ -0,0 +1,79 @@ +)|null $wpBlocks null = parse_blocks(). */ + public function __construct(private ?string $file = null, private ?Closure $wpBlocks = null) + { + } + + /** @return list */ + public function problems(): array + { + $file = $this->file ?? self::REFERENCE; + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin file. + $content = is_file($file) ? file_get_contents($file) : false; + if (! is_string($content) || $content === '') { + return ['Der Referenzinhalt der Prüfung fehlt im Plugin.']; + } + + $document = (new Parser())->parse($content); + $problems = []; + if (Serializer::document($document) !== $content) { + $problems[] = 'Parser und Serializer geben den Referenzinhalt nicht byte-gleich zurück.'; + } + $wordPress = ($this->wpBlocks ?? static fn (string $markup): array => parse_blocks($markup))($content); + if (self::structure($document->children()) !== self::wpStructure($wordPress)) { + $problems[] = 'WordPress liest eine andere Blockstruktur als LIT MCP.'; + } + + return $problems; + } + + /** + * @param list $blocks + * @return list}> + */ + private static function structure(array $blocks): array + { + return array_map( + static fn (Block $block): array => [$block->name(), self::structure($block->children())], + $blocks + ); + } + + /** + * @param array $blocks parse_blocks() result; freeform parts (blockName null) are left out. + * @return list}> + */ + private static function wpStructure(array $blocks): array + { + $structure = []; + foreach ($blocks as $block) { + if (is_array($block) && is_string($block['blockName'] ?? null)) { + $inner = is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : []; + $structure[] = [$block['blockName'], self::wpStructure($inner)]; + } + } + + return $structure; + } +} diff --git a/src/Integrations/Divi/Release/WriteProbe.php b/src/Integrations/Divi/Release/WriteProbe.php new file mode 100644 index 0000000..dfab546 --- /dev/null +++ b/src/Integrations/Divi/Release/WriteProbe.php @@ -0,0 +1,218 @@ + 'section', + 'label' => 'LIT MCP Prüfung', + 'rows' => [ + [ + 'columns' => '1_2,1_2', + 'modules' => [ + [ + ['type' => 'heading', 'text' => 'litcheck-heading', 'level' => 'h2'], + ['type' => 'text', 'content' => '

litcheck-text

'], + ['type' => 'button', 'text' => 'litcheck-button', 'url' => '/'], + ], + [ + ['type' => 'blurb', 'title' => 'litcheck-blurb', 'content' => '

litcheck-blurb-text

'], + [ + 'type' => 'cta', + 'title' => 'litcheck-cta', + 'button_text' => 'litcheck-cta-button', + 'button_url' => '/', + ], + ['type' => 'divider'], + ], + ], + ], + [ + 'columns' => '4_4', + 'modules' => [ + ['type' => 'toggle', 'title' => 'litcheck-toggle', 'content' => '

litcheck-toggle-text

'], + ['type' => 'accordion', 'items' => [['title' => 'litcheck-accordion', 'content' => '

a

']]], + ['type' => 'tabs', 'items' => [['title' => 'litcheck-tab', 'content' => '

t

']]], + ['type' => 'icon-list', 'items' => [['text' => 'litcheck-icon-list']]], + ], + ], + ], + ]]; + + public const TEXTS = [ + 'litcheck-heading', 'litcheck-text', 'litcheck-button', 'litcheck-blurb', 'litcheck-cta', 'litcheck-toggle', + 'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list', + ]; + + /** Only pages older than this are orphans – a running check's page is younger. */ + private const ORPHAN_AGE = '10 minutes ago'; + + /** + * @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir(). + */ + public function __construct( + private ContentComposer $composer, + private ?PageRenderer $renderer = null, + private ?string $diviDir = null + ) { + } + + /** @return list German problems; [] = green. */ + public function run(): array + { + $id = wp_insert_post([ + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_title' => self::TITLE . ' ' . gmdate('Y-m-d H:i'), + 'post_content' => '', + 'meta_input' => [self::MARKER => '1'], + ], true); + if ($id instanceof WP_Error || (int) $id <= 0) { + return ['Die Testseite konnte nicht angelegt werden: ' + . ($id instanceof WP_Error ? $id->get_error_message() : 'keine ID')]; + } + $id = (int) $id; + + try { + $problems = $this->exercise($id); + } catch (Throwable $e) { + $problems = ['Schreibtest abgebrochen: ' . $e->getMessage()]; + } finally { + if (! self::delete($id)) { + $problems[] = sprintf( + 'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.', + $id + ); + } + } + + return $problems; + } + + /** @return list IDs of check pages a dead check left behind, deleted now. */ + public static function removeOrphans(): array + { + $ids = get_posts([ + 'post_type' => 'page', + 'post_status' => 'any', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most. + 'meta_key' => self::MARKER, + 'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']], + 'fields' => 'ids', + 'posts_per_page' => 20, + 'no_found_rows' => true, + 'suppress_filters' => true, + ]); + + $removed = []; + foreach (is_array($ids) ? $ids : [] as $id) { + if (self::delete((int) $id)) { + $removed[] = (int) $id; + } + } + + return $removed; + } + + /** @return list */ + private function exercise(int $id): array + { + $written = ReleaseGate::whileProbing( + $id, + fn (): array|WP_Error => $this->composer->write($id, 'page', self::SECTIONS) + ); + if ($written instanceof WP_Error) { + return ['Die Kurzform der Testseite wurde abgelehnt: ' . Errors::clientMessage($written)]; + } + + $post = get_post($id); + if (! $post instanceof WP_Post) { + return ['Die Testseite ist nach dem Schreiben nicht lesbar.']; + } + + $problems = []; + if (! str_contains((string) $post->post_content, ' \ No newline at end of file diff --git a/src/Integrations/Divi/Schema/ShortformCheck.php b/src/Integrations/Divi/Schema/ShortformCheck.php new file mode 100644 index 0000000..f6e8cfa --- /dev/null +++ b/src/Integrations/Divi/Schema/ShortformCheck.php @@ -0,0 +1,51 @@ + German problems; [] = the short form matches. */ + public static function problems(ModuleCatalog $catalog): array + { + $problems = []; + foreach (ShortformMap::types() as $type) { + $name = ModuleCatalog::normalize($type); + if (! $catalog->has($name)) { + $problems[] = $name . ': unbekanntes Modul'; + continue; + } + + foreach (ShortformMap::fields($type) as $field => $spec) { + if (! $catalog->isKnownPath($name, (string) $spec['path'])) { + $problems[] = sprintf( + '%s.%s: unbekannter Pfad %s (ähnlich: %s)', + $type, + $field, + $spec['path'], + implode(', ', $catalog->suggestions($name, (string) $spec['path'])) + ); + } + } + + $child = ShortformMap::childType($type); + if ($child !== null && ! in_array(ModuleCatalog::normalize($child), $catalog->childrenOf($name), true)) { + $problems[] = sprintf( + '%s: %s ist kein erlaubtes Kind (%s)', + $type, + $child, + implode(', ', $catalog->childrenOf($name)) + ); + } + } + + return $problems; + } +} diff --git a/tests/Integration/divi-shortform-check.php b/tests/Integration/divi-shortform-check.php index 2489ed7..17afd15 100644 --- a/tests/Integration/divi-shortform-check.php +++ b/tests/Integration/divi-shortform-check.php @@ -10,7 +10,7 @@ defined('WP_CLI') && WP_CLI || exit; use LIT\MCP\Integrations\Divi\Schema\DiviModules; -use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog; +use LIT\MCP\Integrations\Divi\Schema\ShortformCheck; use LIT\MCP\Integrations\Divi\Schema\ShortformMap; if (! DiviModules::isAvailable()) { @@ -19,31 +19,7 @@ if (! DiviModules::isAvailable()) { $started = microtime(true); $catalog = (new DiviModules())->catalog(); -$problems = []; -foreach (ShortformMap::types() as $type) { - $name = ModuleCatalog::normalize($type); - if (! $catalog->has($name)) { - $problems[] = $name . ': unknown module'; - continue; - } - - foreach (ShortformMap::fields($type) as $field => $spec) { - if (! $catalog->isKnownPath($name, (string) $spec['path'])) { - $problems[] = sprintf( - '%s.%s: unknown path %s (closest: %s)', - $type, - $field, - $spec['path'], - implode(', ', $catalog->suggestions($name, (string) $spec['path'])) - ); - } - } - - $child = ShortformMap::childType($type); - if ($child !== null && ! in_array(ModuleCatalog::normalize($child), $catalog->childrenOf($name), true)) { - $problems[] = sprintf('%s: %s is no allowed child (%s)', $type, $child, implode(', ', $catalog->childrenOf($name))); - } -} +$problems = ShortformCheck::problems($catalog); if ($problems !== []) { WP_CLI::error("Short form does not match the catalog:\n" . implode("\n", $problems)); diff --git a/tests/Unit/Integrations/Divi/Release/CheckReportTest.php b/tests/Unit/Integrations/Divi/Release/CheckReportTest.php new file mode 100644 index 0000000..959034b --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/CheckReportTest.php @@ -0,0 +1,61 @@ + new CheckStep($key, []), + [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] + ); + } + + public function testGreenOnlyWithEveryStepGreenAndNoneSkipped(): void + { + self::assertTrue(self::report(self::green())->green()); + self::assertFalse(self::report([])->green()); + + $red = self::green(); + $red[2] = new CheckStep(CheckStep::CATALOG, ['divi/text: unbekanntes Modul']); + self::assertFalse(self::report($red)->green()); + + $skipped = self::green(); + $skipped[4] = new CheckStep(CheckStep::WRITE, [], true); + self::assertFalse(self::report($skipped)->green()); + } + + public function testFreshOnlyForTheSameVersionsAndStateKey(): void + { + $report = self::report(self::green()); + + self::assertTrue($report->freshFor('5.3.0', '0.6.0', 'key')); + self::assertFalse($report->freshFor('5.3.1', '0.6.0', 'key'), 'Divi updated'); + self::assertFalse($report->freshFor('5.3.0', '0.7.0', 'key'), 'plugin updated'); + self::assertFalse($report->freshFor('5.3.0', '0.6.0', 'other'), 'a Divi file changed'); + } + + public function testRoundTripsThroughAnArray(): void + { + $steps = self::green(); + $steps[1] = new CheckStep(CheckStep::MIRRORS, ['Theme Builder: Nachbau von x() – y']); + $report = self::report($steps); + + self::assertEquals($report, CheckReport::fromArray($report->toArray())); + self::assertNull(CheckReport::fromArray('broken')); + self::assertNull(CheckReport::fromArray(['id' => 'r1'])); + self::assertSame('Fingerabdrücke nachgebauter Divi-Funktionen', $report->step(CheckStep::MIRRORS)?->label()); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php b/tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php new file mode 100644 index 0000000..37d4671 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php @@ -0,0 +1,92 @@ +justReturn('uuid-1'); + $manifests = Mockery::mock(ManifestCheck::class); + $manifests->allows('codeChecks')->andReturn($code); + $manifests->allows('registrationChecks')->andReturn([]); + $manifests->allows('mirrorChecks')->andReturn($mirrors); + $format = Mockery::mock(FormatCheck::class); + $format->allows('problems')->andReturn([]); + if ($probe === null) { + $probe = Mockery::mock(WriteProbe::class); + $probe->allows('run')->andReturn([]); + } + + return new CompatibilityCheck( + $manifests, + $catalog ?? static fn (): array => [], + $format, + $probe, + static fn (): string => '5.3.0', + static fn (string $version): string => 'key-' . $version + ); + } + + public function testAGreenRunNamesVersionsAndStateKey(): void + { + $report = $this->check([], [])->run(7); + + self::assertTrue($report->green()); + self::assertSame(['uuid-1', 7, '5.3.0', Plugin::VERSION, 'key-5.3.0'], [ + $report->id, $report->userId, $report->diviVersion, $report->pluginVersion, $report->stateKey, + ]); + self::assertSame( + ['manifest', 'mirrors', 'catalog', 'format', 'write'], + array_map(static fn (CheckStep $step): string => $step->key, $report->steps) + ); + } + + public function testNoWriteTestWhenDiviFunctionsAreMissing(): void + { + $probe = Mockery::mock(WriteProbe::class); + $probe->expects('run')->never(); + + $report = $this->check(['Seiten: et_x()' => static fn (): bool => false], [], $probe)->run(7); + + self::assertSame(['Seiten: et_x()'], $report->step(CheckStep::MANIFEST)?->problems); + self::assertTrue($report->step(CheckStep::WRITE)?->skipped); + } + + public function testChangedFingerprintsStillRunTheWriteTest(): void + { + $probe = Mockery::mock(WriteProbe::class); + $probe->expects('run')->once()->andReturn([]); + + $report = $this->check([], ['TB: Nachbau von y()' => static fn (): bool => false], $probe)->run(7); + + self::assertFalse($report->green()); + self::assertTrue($report->step(CheckStep::WRITE)?->green()); + } + + public function testAThrowingStepIsARedStep(): void + { + $report = $this->check([], [], null, static function (): never { + throw new RuntimeException('Katalog weg'); + })->run(7); + + self::assertSame(['Prüfschritt abgebrochen: Katalog weg'], $report->step(CheckStep::CATALOG)?->problems); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/FormatCheckTest.php b/tests/Unit/Integrations/Divi/Release/FormatCheckTest.php new file mode 100644 index 0000000..d9826b0 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/FormatCheckTest.php @@ -0,0 +1,50 @@ + [ + 'blockName' => $block->name(), + 'innerBlocks' => $convert($block->children()), + ], $blocks); + }; + + return static fn (string $content): array => $convert((new Parser())->parse($content)->children()); + } + + public function testTheShippedReferenceRoundTripsAndMatchesWordPress(): void + { + self::assertSame([], (new FormatCheck(null, self::wordPress()))->problems()); + self::assertFileEquals( + dirname(__DIR__, 5) . '/tests/Fixtures/divi/converted-landing.html', + FormatCheck::REFERENCE + ); + } + + public function testADifferentStructureInWordPressIsReported(): void + { + $check = new FormatCheck(null, static fn (): array => [['blockName' => 'divi/section', 'innerBlocks' => []]]); + + self::assertSame(['WordPress liest eine andere Blockstruktur als LIT MCP.'], $check->problems()); + } + + public function testAMissingReferenceIsReported(): void + { + self::assertSame( + ['Der Referenzinhalt der Prüfung fehlt im Plugin.'], + (new FormatCheck('/nonexistent/reference.html', self::wordPress()))->problems() + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php new file mode 100644 index 0000000..6e0aa16 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php @@ -0,0 +1,159 @@ + */ + private array $deleted = []; + + protected function setUp(): void + { + parent::setUp(); + // The probe must work while Divi is untested – that is when it runs. + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0')); + $this->deleted = []; + Functions\when('wp_insert_post')->justReturn(41); + Functions\when('get_post')->justReturn( + new WP_Post(['ID' => 41, 'post_content' => '']) + ); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on' + ); + Functions\when('wp_delete_post')->alias(function (int $id, bool $force): WP_Post { + self::assertTrue($force, 'never to the trash'); + $this->deleted[] = $id; + + return new WP_Post(['ID' => $id]); + }); + } + + private function composer(?callable $write = null): ContentComposer + { + $composer = Mockery::mock(ContentComposer::class); + $default = static function (int $id, string $type, array $sections): array { + // The composer writes through the saver, which asks the gate: the probe must have opened it for 41. + ReleaseGate::assertWritable($id); + + return ['warnings' => [], 'outline' => '']; + }; + $composer->allows('write')->andReturnUsing($write ?? $default); + + return $composer; + } + + private function renderer(string $html): PageRenderer + { + $renderer = Mockery::mock(PageRenderer::class); + $renderer->allows('render')->andReturn($html); + + return $renderer; + } + + public function testGreenWhenEveryModuleRendersAndThePageIsGone(): void + { + $probe = new WriteProbe( + $this->composer(), + $this->renderer(implode(' ', WriteProbe::TEXTS)), + '/nonexistent-divi' + ); + + self::assertSame([], $probe->run()); + self::assertSame([41], $this->deleted); + } + + public function testMissingTextsAndFlagsAreReported(): void + { + Functions\when('get_post_meta')->justReturn(''); + + $probe = new WriteProbe($this->composer(), $this->renderer('litcheck-heading'), '/nonexistent-divi'); + $problems = $probe->run(); + + self::assertContains('Builder-Kennzeichen _et_pb_use_builder fehlt.', $problems); + self::assertContains('„litcheck-button“ fehlt in der gerenderten Seite.', $problems); + self::assertNotContains('„litcheck-heading“ fehlt in der gerenderten Seite.', $problems); + self::assertSame([41], $this->deleted); + } + + public function testDeletesThePageEvenWhenWritingThrows(): void + { + $composer = $this->composer(static function (): never { + throw new RuntimeException('Composer kaputt'); + }); + + $problems = (new WriteProbe($composer, $this->renderer(''), '/nonexistent-divi'))->run(); + + self::assertSame(['Schreibtest abgebrochen: Composer kaputt'], $problems); + self::assertSame([41], $this->deleted); + $this->expectException(\LIT\MCP\Integrations\Divi\Release\DiviLocked::class); + ReleaseGate::assertWritable(41); + } + + public function testReportsAPageThatCouldNotBeDeleted(): void + { + Functions\when('wp_delete_post')->justReturn(false); + + $probe = new WriteProbe( + $this->composer(), + $this->renderer(implode(' ', WriteProbe::TEXTS)), + '/nonexistent-divi' + ); + $problems = $probe->run(); + + self::assertSame( + ['Die Testseite 41 konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.'], + $problems + ); + } + + public function testCreatesAMarkedDraftPage(): void + { + // wp_insert_post is already stubbed in setUp(); Functions\expect() cannot rewire the same + // function again (Brain Monkey keeps the first redefinition), so the args are captured here. + $captured = null; + Functions\when('wp_insert_post')->alias(static function (array $post, bool $wpError) use (&$captured): int { + $captured = $post; + + return 41; + }); + + $probe = new WriteProbe( + $this->composer(), + $this->renderer(implode(' ', WriteProbe::TEXTS)), + '/nonexistent-divi' + ); + $probe->run(); + + self::assertIsArray($captured); + self::assertSame('page', $captured['post_type']); + self::assertSame('draft', $captured['post_status']); + self::assertTrue(str_starts_with($captured['post_title'], '[LIT MCP Prüfung] ')); + self::assertSame(['_lit_mcp_check_page' => '1'], $captured['meta_input']); + } + + public function testRemovesOrphanedCheckPages(): void + { + Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $query): bool => + $query['meta_key'] === '_lit_mcp_check_page' + && $query['post_status'] === 'any' + && $query['fields'] === 'ids' + && ($query['date_query'][0]['before'] ?? null) === '10 minutes ago'))->andReturn([51, 52]); + + self::assertSame([51, 52], WriteProbe::removeOrphans()); + self::assertSame([51, 52], $this->deleted); + } +} diff --git a/tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php b/tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php new file mode 100644 index 0000000..85d7421 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php @@ -0,0 +1,31 @@ +