From 43c0a78374987cc2ffdb0410c4bc0c134a3eb06c Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 03:33:16 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Divi-Reiter=20ohne=20Divi,=20Zustandstex?= =?UTF-8?q?te,=20Schreibsperren=20und=20Aufr=C3=A4um-Filter=20aus=20der=20?= =?UTF-8?q?Abschlusspr=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behebt die fünf Befunde der Abschlussprüfung von divi-absicherung: Divi-Reiter ohne aktives Divi zeigt nur noch einen Hinweis (kein Status, keine Prüfung, kein Aufräumen), Zustandstexte werden erst beim Rendern übersetzt, DiviPages::setFlags() und DiviThemeBuilder::liveId(true) prüfen jetzt selbst die Freigabesperre, WriteProbe::removeOrphans() löscht nur noch Entwürfe mit passendem Titel, und wp lit-mcp divi-check --update-reference verweigert sich ohne Entwicklungskopie, bevor irgendetwas geschrieben wird. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Admin/DiviTab.php | 27 +++++++++++++----- src/Cli/DiviCheckCommand.php | 10 +++++-- src/Integrations/Divi/Pages/DiviPages.php | 8 +++++- src/Integrations/Divi/Release/WriteProbe.php | 20 ++++++++----- .../Divi/ThemeBuilder/DiviThemeBuilder.php | 10 ++++++- tests/Unit/Admin/DiviTabTest.php | 28 +++++++++++++++++++ tests/Unit/Cli/DiviCheckCommandTest.php | 21 ++++++++++++-- .../Divi/Release/ReleaseGuardsTest.php | 3 ++ .../Divi/Release/WriteProbeTest.php | 19 +++++++++++-- 9 files changed, 122 insertions(+), 24 deletions(-) diff --git a/src/Admin/DiviTab.php b/src/Admin/DiviTab.php index 263707b..d1bff9c 100644 --- a/src/Admin/DiviTab.php +++ b/src/Admin/DiviTab.php @@ -31,12 +31,6 @@ final class DiviTab implements AdminTab public const RELEASE_ACTION = 'lit_mcp_divi_release'; public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw'; - private const STATES = [ - 'released' => 'freigegeben', - 'untested' => 'ungetestet', - 'incompatible' => 'inkompatibel', - ]; - /** * @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment. * @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey(). @@ -123,6 +117,16 @@ final class DiviTab implements AdminTab public function render(): void { + if ($this->version() === '') { + echo '

' . esc_html__( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text. + 'Divi ist auf dieser Website nicht aktiv; die Divi-Freigabe ist ohne Bedeutung.', + 'wp-lit-mcp' + ) . '

'; + + return; + } + $removed = WriteProbe::removeOrphans(); $status = ReleaseGate::status(); $report = $this->reports()->latest(); @@ -162,7 +166,7 @@ final class DiviTab implements AdminTab printf( '%s%s – %s', esc_html__('Zustand', 'wp-lit-mcp'), - esc_html(self::STATES[$status->state->value]), + esc_html($this->stateLabel($status->state)), esc_html($this->explanation($status)) ); if ($status->problems !== []) { @@ -175,6 +179,15 @@ final class DiviTab implements AdminTab echo ''; } + private function stateLabel(ReleaseState $state): string + { + return match ($state) { + ReleaseState::Released => __('freigegeben', 'wp-lit-mcp'), + ReleaseState::Untested => __('ungetestet', 'wp-lit-mcp'), + ReleaseState::Incompatible => __('inkompatibel', 'wp-lit-mcp'), + }; + } + private function explanation(ReleaseStatus $status): string { if ($status->state === ReleaseState::Incompatible) { diff --git a/src/Cli/DiviCheckCommand.php b/src/Cli/DiviCheckCommand.php index 0ec9779..f91dc7d 100644 --- a/src/Cli/DiviCheckCommand.php +++ b/src/Cli/DiviCheckCommand.php @@ -161,6 +161,13 @@ final class DiviCheckCommand } } + $sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))(); + if ($sourceDir === null) { + ($this->out)('Referenz nicht aktualisiert: nur in einer Entwicklungskopie mit tests/ möglich.'); + + return 1; + } + $themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))(); try { $prints = DiviReference::current((string) $themeDir); @@ -172,11 +179,10 @@ final class DiviCheckCommand return 1; } - $sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))(); try { // Sources first, fingerprints.json last: the fingerprints decide the release gate, so a write that // fails before this last one leaves the gate exactly as it was. - foreach ($sourceDir === null ? [] : $prints as $id => $print) { + foreach ($prints as $id => $print) { DiviReference::writeSource($id, $print->source, $sourceDir); } DiviReference::writeExpected( diff --git a/src/Integrations/Divi/Pages/DiviPages.php b/src/Integrations/Divi/Pages/DiviPages.php index 147c653..fa35d4a 100644 --- a/src/Integrations/Divi/Pages/DiviPages.php +++ b/src/Integrations/Divi/Pages/DiviPages.php @@ -6,8 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Integrations\Divi\Release\Mirror; +use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use RuntimeException; use WP_Post; @@ -50,9 +52,13 @@ final class DiviPages implements DiviAdapter ); } - /** @throws RuntimeException When a flag does not read back as set. */ + /** + * @throws RuntimeException When a flag does not read back as set. + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + */ public static function setFlags(int $postId): void { + ReleaseGate::assertWritable($postId); foreach (self::FLAGS as $key => $value) { update_post_meta($postId, $key, $value); } diff --git a/src/Integrations/Divi/Release/WriteProbe.php b/src/Integrations/Divi/Release/WriteProbe.php index dfab546..6a3b3e2 100644 --- a/src/Integrations/Divi/Release/WriteProbe.php +++ b/src/Integrations/Divi/Release/WriteProbe.php @@ -114,25 +114,31 @@ class WriteProbe return $problems; } - /** @return list IDs of check pages a dead check left behind, deleted now. */ + /** + * @return list IDs of check pages a dead check left behind, deleted now. Only drafts whose title starts + * with self::TITLE are considered – the marker meta alone is not enough (it is only a query hint here). + */ public static function removeOrphans(): array { - $ids = get_posts([ + $posts = get_posts([ 'post_type' => 'page', - 'post_status' => 'any', + 'post_status' => 'draft', // 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', + 'fields' => 'all', '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; + foreach (is_array($posts) ? $posts : [] as $post) { + if (! $post instanceof WP_Post || ! str_starts_with((string) $post->post_title, self::TITLE)) { + continue; + } + if (self::delete((int) $post->ID)) { + $removed[] = (int) $post->ID; } } diff --git a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php index c2a9871..29dbd55 100644 --- a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php +++ b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php @@ -126,9 +126,17 @@ class DiviThemeBuilder implements DiviAdapter ); } - /** The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. */ + /** + * The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. + * + * @throws DiviLocked When $create and Divi is not released (spec §5.5) – before anything is written. + */ public function liveId(bool $create = false): int { + if ($create) { + ReleaseGate::assertWritable(); + } + return function_exists('et_theme_builder_get_theme_builder_post_id') ? (int) et_theme_builder_get_theme_builder_post_id(true, $create) : 0; diff --git a/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php index a26c1a1..c5cd754 100644 --- a/tests/Unit/Admin/DiviTabTest.php +++ b/tests/Unit/Admin/DiviTabTest.php @@ -200,6 +200,34 @@ final class DiviTabTest extends TestCase self::assertStringContainsString('Freigabe zurückziehen', $this->rendered($status, self::report())); } + public function testWithoutDiviShowsOnlyAShortNotice(): void + { + ReleaseGate::useResolver(static function (): ReleaseStatus { + self::fail('resolver called although Divi is not active'); + }); + $reports = Mockery::mock(CheckReportStore::class); + $reports->expects('latest')->never(); + $tab = new DiviTab( + $reports, + Mockery::mock(ReleaseStore::class), + FakeChangeLog::forbidden(), + new AdminPostAction(static function (): void { + }), + static fn (): string => '', + static fn (string $version): string => 'key' + ); + + ob_start(); + $tab->render(); + $html = (string) ob_get_clean(); + + self::assertStringContainsString( + 'Divi ist auf dieser Website nicht aktiv; die Divi-Freigabe ist ohne Bedeutung.', + $html + ); + self::assertStringNotContainsString('Prüfung starten', $html); + } + public function testIncompatibleListsTheProblems(): void { $html = $this->rendered( diff --git a/tests/Unit/Cli/DiviCheckCommandTest.php b/tests/Unit/Cli/DiviCheckCommandTest.php index 859e49e..df93374 100644 --- a/tests/Unit/Cli/DiviCheckCommandTest.php +++ b/tests/Unit/Cli/DiviCheckCommandTest.php @@ -49,8 +49,11 @@ final class DiviCheckCommandTest extends TestCase * @param bool $expectSave True asserts save() is called exactly once (the green happy path); otherwise it is * merely allowed, since some tests (e.g. no admin) never reach the save() call. */ - private function command(array $problems = [], bool $expectSave = false): DiviCheckCommand - { + private function command( + array $problems = [], + bool $expectSave = false, + bool $noSourceDir = false + ): DiviCheckCommand { $steps = array_map( static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []), [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] @@ -72,7 +75,7 @@ final class DiviCheckCommandTest extends TestCase }, fn (): ?string => $this->themeDir, $this->expectedFile, - fn (): ?string => $this->sourceDir + $noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir ); } @@ -105,6 +108,18 @@ final class DiviCheckCommandTest extends TestCase self::assertFileDoesNotExist($this->expectedFile); } + public function testUpdateReferenceRefusesWithoutADevelopmentSourceDir(): void + { + $code = $this->command(noSourceDir: true)->run(['update-reference' => true]); + + self::assertSame(1, $code); + self::assertStringContainsString( + 'Referenz nicht aktualisiert: nur in einer Entwicklungskopie mit tests/ möglich.', + implode("\n", $this->lines) + ); + self::assertFileDoesNotExist($this->expectedFile); + } + public function testDiffShowsWhatChangedInAMirroredFunction(): void { file_put_contents( diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php index 3accd9a..be2c82d 100644 --- a/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php +++ b/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec; +use LIT\MCP\Integrations\Divi\Pages\DiviPages; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\ReleaseGate; @@ -56,6 +57,8 @@ final class ReleaseGuardsTest extends TestCase yield 'tb setConditions' => [static fn () => (new DiviThemeBuilder())->setConditions(9, ['singular'], [])]; yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true)]; yield 'tb unmarkUnused' => [static fn () => (new DiviThemeBuilder())->unmarkUnused(9)]; + yield 'pages setFlags' => [static fn () => DiviPages::setFlags(7)]; + yield 'tb liveId create' => [static fn () => (new DiviThemeBuilder())->liveId(true)]; } /** @dataProvider writes */ diff --git a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php index 6e0aa16..4f92374 100644 --- a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php +++ b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php @@ -149,11 +149,24 @@ final class WriteProbeTest extends TestCase { 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]); + && $query['post_status'] === 'draft' + && $query['fields'] === 'all' + && ($query['date_query'][0]['before'] ?? null) === '10 minutes ago'))->andReturn([ + new WP_Post(['ID' => 51, 'post_title' => WriteProbe::TITLE . ' 2026-01-01 00:00']), + new WP_Post(['ID' => 52, 'post_title' => WriteProbe::TITLE . ' 2026-01-02 00:00']), + ]); self::assertSame([51, 52], WriteProbe::removeOrphans()); self::assertSame([51, 52], $this->deleted); } + + public function testKeepsAMarkedDraftWhoseTitleDoesNotMatch(): void + { + Functions\when('get_posts')->justReturn([ + new WP_Post(['ID' => 60, 'post_title' => 'Ein anderer Entwurf']), + ]); + + self::assertSame([], WriteProbe::removeOrphans()); + self::assertSame([], $this->deleted); + } }