fix: Divi-Reiter ohne Divi, Zustandstexte, Schreibsperren und Aufräum-Filter aus der Abschlussprüfung

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:33:16 +02:00
co-authored by Claude Opus 5.5
parent 35ea47d832
commit 43c0a78374
9 changed files with 122 additions and 24 deletions
+20 -7
View File
@@ -31,12 +31,6 @@ final class DiviTab implements AdminTab
public const RELEASE_ACTION = 'lit_mcp_divi_release'; public const RELEASE_ACTION = 'lit_mcp_divi_release';
public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw'; 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)|null $version Installed Divi version ('' = none); null = DiviEnvironment.
* @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey(). * @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey().
@@ -123,6 +117,16 @@ final class DiviTab implements AdminTab
public function render(): void public function render(): void
{ {
if ($this->version() === '') {
echo '<p>' . 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'
) . '</p>';
return;
}
$removed = WriteProbe::removeOrphans(); $removed = WriteProbe::removeOrphans();
$status = ReleaseGate::status(); $status = ReleaseGate::status();
$report = $this->reports()->latest(); $report = $this->reports()->latest();
@@ -162,7 +166,7 @@ final class DiviTab implements AdminTab
printf( printf(
'<tr><th scope="row">%s</th><td><strong>%s</strong> – %s</td></tr>', '<tr><th scope="row">%s</th><td><strong>%s</strong> – %s</td></tr>',
esc_html__('Zustand', 'wp-lit-mcp'), esc_html__('Zustand', 'wp-lit-mcp'),
esc_html(self::STATES[$status->state->value]), esc_html($this->stateLabel($status->state)),
esc_html($this->explanation($status)) esc_html($this->explanation($status))
); );
if ($status->problems !== []) { if ($status->problems !== []) {
@@ -175,6 +179,15 @@ final class DiviTab implements AdminTab
echo '</tbody></table>'; echo '</tbody></table>';
} }
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 private function explanation(ReleaseStatus $status): string
{ {
if ($status->state === ReleaseState::Incompatible) { if ($status->state === ReleaseState::Incompatible) {
+8 -2
View File
@@ -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(...))(); $themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))();
try { try {
$prints = DiviReference::current((string) $themeDir); $prints = DiviReference::current((string) $themeDir);
@@ -172,11 +179,10 @@ final class DiviCheckCommand
return 1; return 1;
} }
$sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))();
try { try {
// Sources first, fingerprints.json last: the fingerprints decide the release gate, so a write that // 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. // 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::writeSource($id, $print->source, $sourceDir);
} }
DiviReference::writeExpected( DiviReference::writeExpected(
+7 -1
View File
@@ -6,8 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; 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\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror; use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException; use RuntimeException;
use WP_Post; 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 public static function setFlags(int $postId): void
{ {
ReleaseGate::assertWritable($postId);
foreach (self::FLAGS as $key => $value) { foreach (self::FLAGS as $key => $value) {
update_post_meta($postId, $key, $value); update_post_meta($postId, $key, $value);
} }
+13 -7
View File
@@ -114,25 +114,31 @@ class WriteProbe
return $problems; return $problems;
} }
/** @return list<int> IDs of check pages a dead check left behind, deleted now. */ /**
* @return list<int> 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 public static function removeOrphans(): array
{ {
$ids = get_posts([ $posts = get_posts([
'post_type' => 'page', '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. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most.
'meta_key' => self::MARKER, 'meta_key' => self::MARKER,
'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']], 'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']],
'fields' => 'ids', 'fields' => 'all',
'posts_per_page' => 20, 'posts_per_page' => 20,
'no_found_rows' => true, 'no_found_rows' => true,
'suppress_filters' => true, 'suppress_filters' => true,
]); ]);
$removed = []; $removed = [];
foreach (is_array($ids) ? $ids : [] as $id) { foreach (is_array($posts) ? $posts : [] as $post) {
if (self::delete((int) $id)) { if (! $post instanceof WP_Post || ! str_starts_with((string) $post->post_title, self::TITLE)) {
$removed[] = (int) $id; continue;
}
if (self::delete((int) $post->ID)) {
$removed[] = (int) $post->ID;
} }
} }
@@ -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 public function liveId(bool $create = false): int
{ {
if ($create) {
ReleaseGate::assertWritable();
}
return function_exists('et_theme_builder_get_theme_builder_post_id') return function_exists('et_theme_builder_get_theme_builder_post_id')
? (int) et_theme_builder_get_theme_builder_post_id(true, $create) ? (int) et_theme_builder_get_theme_builder_post_id(true, $create)
: 0; : 0;
+28
View File
@@ -200,6 +200,34 @@ final class DiviTabTest extends TestCase
self::assertStringContainsString('Freigabe zurückziehen', $this->rendered($status, self::report())); 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 public function testIncompatibleListsTheProblems(): void
{ {
$html = $this->rendered( $html = $this->rendered(
+18 -3
View File
@@ -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 * @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. * 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( $steps = array_map(
static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []), static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []),
[CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
@@ -72,7 +75,7 @@ final class DiviCheckCommandTest extends TestCase
}, },
fn (): ?string => $this->themeDir, fn (): ?string => $this->themeDir,
$this->expectedFile, $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); 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 public function testDiffShowsWhatChangedInAMirroredFunction(): void
{ {
file_put_contents( file_put_contents(
@@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec; 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\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; 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 setConditions' => [static fn () => (new DiviThemeBuilder())->setConditions(9, ['singular'], [])];
yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true)]; yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true)];
yield 'tb unmarkUnused' => [static fn () => (new DiviThemeBuilder())->unmarkUnused(9)]; 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 */ /** @dataProvider writes */
@@ -149,11 +149,24 @@ final class WriteProbeTest extends TestCase
{ {
Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $query): bool => Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $query): bool =>
$query['meta_key'] === '_lit_mcp_check_page' $query['meta_key'] === '_lit_mcp_check_page'
&& $query['post_status'] === 'any' && $query['post_status'] === 'draft'
&& $query['fields'] === 'ids' && $query['fields'] === 'all'
&& ($query['date_query'][0]['before'] ?? null) === '10 minutes ago'))->andReturn([51, 52]); && ($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], WriteProbe::removeOrphans());
self::assertSame([51, 52], $this->deleted); 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);
}
} }