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:
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** The result of one admin check (spec §6.3); a release needs a fresh green one. */
|
||||
final class CheckReport
|
||||
{
|
||||
/** @param list<CheckStep> $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<string, mixed> */
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** The latest admin check report of this site (spec §6.3). Not final so the Divi tab can be tested with a mock. */
|
||||
class CheckReportStore
|
||||
{
|
||||
public const OPTION = 'lit_mcp_divi_check_report';
|
||||
|
||||
public function latest(): ?CheckReport
|
||||
{
|
||||
return CheckReport::fromArray(get_option(self::OPTION, null));
|
||||
}
|
||||
|
||||
public function save(CheckReport $report): void
|
||||
{
|
||||
update_option(self::OPTION, $report->toArray(), false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** One step of the admin check (spec §6.2); skipped = not run (the write test after missing Divi functions). */
|
||||
final class CheckStep
|
||||
{
|
||||
public const MANIFEST = 'manifest';
|
||||
public const MIRRORS = 'mirrors';
|
||||
public const CATALOG = 'catalog';
|
||||
public const FORMAT = 'format';
|
||||
public const WRITE = 'write';
|
||||
|
||||
private const LABELS = [
|
||||
self::MANIFEST => '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<string> $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<string>, 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||||
use LIT\MCP\Plugin;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The admin check (spec §6.2) – also wp lit-mcp divi-check: manifests (code and registrations), fingerprints,
|
||||
* module catalog, format round trip and the write test. A step that throws is a red step; the write test is skipped
|
||||
* only when Divi functions are missing (it would fail anyway), not when only fingerprints changed – so
|
||||
* --update-reference can see whether everything else still works. Not final so the route and the command can be
|
||||
* tested with a mock.
|
||||
*/
|
||||
class CompatibilityCheck
|
||||
{
|
||||
/**
|
||||
* @param Closure(): list<string> $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<string> */
|
||||
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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Spec §6.2 step 3: a builder-made Divi 5 page (reference-page.html, from the converted fixture) parses and
|
||||
* serializes byte-identically, and WordPress' parse_blocks() sees the same block names and nesting as the plugin's
|
||||
* parser. Nothing is written. Not final so CompatibilityCheck can be tested with a mock.
|
||||
*/
|
||||
class FormatCheck
|
||||
{
|
||||
public const REFERENCE = __DIR__ . '/reference-page.html';
|
||||
|
||||
/** @param (Closure(string): array<int, mixed>)|null $wpBlocks null = parse_blocks(). */
|
||||
public function __construct(private ?string $file = null, private ?Closure $wpBlocks = null)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
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<Block> $blocks
|
||||
* @return list<array{0: string, 1: list<mixed>}>
|
||||
*/
|
||||
private static function structure(array $blocks): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (Block $block): array => [$block->name(), self::structure($block->children())],
|
||||
$blocks
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $blocks parse_blocks() result; freeform parts (blockName null) are left out.
|
||||
* @return list<array{0: string, 1: list<mixed>}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||||
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Spec §6.2 step 4: a throwaway draft page written through the normal Divi write path (ContentComposer → Composer
|
||||
* → Serializer → DiviSaver, with the release lock opened for this page only), checked (content, builder flags,
|
||||
* Divi renders every module without a PHP warning from Divi's files) and always deleted for good – in finally,
|
||||
* not to the trash, with its revisions and meta. Pages a dead check left behind carry MARKER; removeOrphans()
|
||||
* deletes them. No change log entry. Not final so CompatibilityCheck can be tested with a mock.
|
||||
*/
|
||||
class WriteProbe
|
||||
{
|
||||
public const MARKER = '_lit_mcp_check_page';
|
||||
|
||||
public const TITLE = '[LIT MCP Prüfung]';
|
||||
|
||||
/** Everyday modules without media, each with a text to find in the rendered page. */
|
||||
public const SECTIONS = [[
|
||||
'type' => 'section',
|
||||
'label' => 'LIT MCP Prüfung',
|
||||
'rows' => [
|
||||
[
|
||||
'columns' => '1_2,1_2',
|
||||
'modules' => [
|
||||
[
|
||||
['type' => 'heading', 'text' => 'litcheck-heading', 'level' => 'h2'],
|
||||
['type' => 'text', 'content' => '<p>litcheck-text</p>'],
|
||||
['type' => 'button', 'text' => 'litcheck-button', 'url' => '/'],
|
||||
],
|
||||
[
|
||||
['type' => 'blurb', 'title' => 'litcheck-blurb', 'content' => '<p>litcheck-blurb-text</p>'],
|
||||
[
|
||||
'type' => 'cta',
|
||||
'title' => 'litcheck-cta',
|
||||
'button_text' => 'litcheck-cta-button',
|
||||
'button_url' => '/',
|
||||
],
|
||||
['type' => 'divider'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'columns' => '4_4',
|
||||
'modules' => [
|
||||
['type' => 'toggle', 'title' => 'litcheck-toggle', 'content' => '<p>litcheck-toggle-text</p>'],
|
||||
['type' => 'accordion', 'items' => [['title' => 'litcheck-accordion', 'content' => '<p>a</p>']]],
|
||||
['type' => 'tabs', 'items' => [['title' => 'litcheck-tab', 'content' => '<p>t</p>']]],
|
||||
['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<string> 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<int> 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<string> */
|
||||
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, '<!-- wp:divi/')) {
|
||||
$problems[] = 'Es wurde kein Divi-Inhalt gespeichert.';
|
||||
}
|
||||
foreach (DiviPages::FLAGS as $key => $value) {
|
||||
if (get_post_meta($id, $key, true) !== $value) {
|
||||
$problems[] = sprintf('Builder-Kennzeichen %s fehlt.', $key);
|
||||
}
|
||||
}
|
||||
|
||||
[$html, $warnings] = $this->render($post);
|
||||
foreach (self::TEXTS as $text) {
|
||||
if (! str_contains($html, $text)) {
|
||||
$problems[] = sprintf('„%s“ fehlt in der gerenderten Seite.', $text);
|
||||
}
|
||||
}
|
||||
foreach ($warnings as $warning) {
|
||||
$problems[] = 'PHP-Warnung aus Divi beim Rendern: ' . $warning;
|
||||
}
|
||||
|
||||
return $problems;
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: list<string>} The rendered content and PHP warnings raised in Divi's files. */
|
||||
private function render(WP_Post $post): array
|
||||
{
|
||||
$dir = $this->diviDir ?? DiviEnvironment::themeDir();
|
||||
$warnings = [];
|
||||
set_error_handler(
|
||||
static function (
|
||||
int $level,
|
||||
string $message,
|
||||
string $file = '',
|
||||
int $line = 0
|
||||
) use (
|
||||
$dir,
|
||||
&$warnings
|
||||
): bool {
|
||||
if ($dir !== null && str_starts_with($file, $dir)) {
|
||||
$warnings[] = sprintf('%s (%s:%d)', $message, basename($file), $line);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
E_WARNING | E_USER_WARNING
|
||||
);
|
||||
|
||||
try {
|
||||
$html = ($this->renderer ?? new PageRenderer())->render($post);
|
||||
} finally {
|
||||
restore_error_handler();
|
||||
}
|
||||
|
||||
return [$html, $warnings];
|
||||
}
|
||||
|
||||
private static function delete(int $id): bool
|
||||
{
|
||||
return wp_delete_post($id, true) instanceof WP_Post;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The curated short form against a module catalog (spec §6.2 step 2): every module exists, every field path is a
|
||||
* known attribute path, every item type is an allowed child. Pure PHP; the admin check passes Divi's live catalog.
|
||||
*/
|
||||
final class ShortformCheck
|
||||
{
|
||||
/** @return list<string> 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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user