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);
+ }
}