feat: admin check steps with throwaway write test and reports

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 02:21:09 +02:00
co-authored by Claude Opus 5.5
parent c6a1996be6
commit 47040aa43d
14 changed files with 1026 additions and 26 deletions
+2 -26
View File
@@ -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));
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use PHPUnit\Framework\TestCase;
final class CheckReportTest extends TestCase
{
private static function report(array $steps): CheckReport
{
return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, '5.3.0', '0.6.0', 'key', $steps);
}
private static function green(): array
{
return array_map(
static fn (string $key): CheckStep => 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());
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class CompatibilityCheckTest extends TestCase
{
private function check(
array $code,
array $mirrors,
?WriteProbe $probe = null,
?\Closure $catalog = null
): CompatibilityCheck {
Functions\when('wp_generate_uuid4')->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);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use PHPUnit\Framework\TestCase;
final class FormatCheckTest extends TestCase
{
/** parse_blocks() as WordPress would answer for well-formed Divi markup: the same names and nesting. */
private static function wordPress(): \Closure
{
$convert = static function (array $blocks) use (&$convert): array {
return array_map(static fn (Block $block): array => [
'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()
);
}
}
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Post;
final class WriteProbeTest extends TestCase
{
/** @var list<int> */
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' => '<!-- wp:divi/section /-->'])
);
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);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
final class ShortformCheckTest extends TestCase
{
public function testAnEmptyCatalogKnowsNoShortformModule(): void
{
$problems = ShortformCheck::problems(new ModuleCatalog([]));
self::assertCount(count(ShortformMap::types()), $problems);
self::assertContains('divi/text: unbekanntes Modul', $problems);
}
/** The fixture catalog (metadata-sample.json) lacks some modules, but every path of the modules it has is right. */
public function testFixtureCatalogHasNoWrongPathsOrChildren(): void
{
foreach (ShortformCheck::problems(DiviFixtures::catalog()) as $problem) {
self::assertStringEndsWith(': unbekanntes Modul', $problem);
}
self::assertNotContains('divi/text: unbekanntes Modul', ShortformCheck::problems(DiviFixtures::catalog()));
}
}