refactor(compat): Freigabe-Bausteine generisch

ReleaseStore, CheckReportStore, CheckReport, CheckStep und CompatibilityCheck liegen im Kern und arbeiten je Profil (Optionen lit_mcp_<id>_releases|_check_report; Divi behält Namen und Schlüssel divi_version). Divi hängt Katalog-, Format- und Schreibtest über Profile::extraSteps an. Ein roter Prüfbericht für den aktuellen Stand sperrt LockOnFailure-Profile bis zur nächsten grünen Prüfung; Sperrgründe nennen nur Schritt-Labels, Ausnahmen gehen ins Error-Log. Profile bekommt testedVersions, stateKey, extraSteps, manifestCheck und reportVersionKey und wird mit benannten Argumenten gebaut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 12:51:03 +01:00
co-authored by Claude Opus 5.5
parent c85131603b
commit 7d6a97a54e
48 changed files with 1469 additions and 647 deletions
@@ -46,10 +46,27 @@ mechanisch (Namespace + `use`), keine Verhaltensänderung. Keine Commits durch U
| 1.12 | `PodsFeature` (`pods-schema-read`, `pods-field-read`, `pods-field-write`, `pods-field-permissions`), `PodsCompatibility::profile()` (LockOnFailure, PODS_VERSION, min 2.8, Calls mit Argumentzahlen gegen Pods 3.3.9.2), `PodsIntegration` ohne Abilities | `PodsCompatibilityTest`; Registry-Test: leere Integration nicht in `grouped()`/`labels()`/Instructions | keine leere Gruppe sichtbar | sonnet | | 1.12 | `PodsFeature` (`pods-schema-read`, `pods-field-read`, `pods-field-write`, `pods-field-permissions`), `PodsCompatibility::profile()` (LockOnFailure, PODS_VERSION, min 2.8, Calls mit Argumentzahlen gegen Pods 3.3.9.2), `PodsIntegration` ohne Abilities | `PodsCompatibilityTest`; Registry-Test: leere Integration nicht in `grouped()`/`labels()`/Instructions | keine leere Gruppe sichtbar | sonnet |
| 1.13 | `CompatibilityComponent` (3 Zustände, Zeile je Fähigkeit); `EnvironmentProbe` ohne Pods (TEC bleibt); `Plugin`: Guards `[DiviFeatureGate, CapabilityGate]`, Komponenten per Closure aus der Registry; 0.3-Umzug (`unknownIds` entfällt, Test zieht in `CapabilityGateTest`); ID-Eindeutigkeit | `EnvironmentProbeTest`; Zwei-Gate-Test über `AbilityRegistrar::argsFor`; `PluginTest` | Reihenfolge Divi, Pods, TEC | sonnet | | 1.13 | `CompatibilityComponent` (3 Zustände, Zeile je Fähigkeit); `EnvironmentProbe` ohne Pods (TEC bleibt); `Plugin`: Guards `[DiviFeatureGate, CapabilityGate]`, Komponenten per Closure aus der Registry; 0.3-Umzug (`unknownIds` entfällt, Test zieht in `CapabilityGateTest`); ID-Eindeutigkeit | `EnvironmentProbeTest`; Zwei-Gate-Test über `AbilityRegistrar::argsFor`; `PluginTest` | Reihenfolge Divi, Pods, TEC | sonnet |
| 1.14 | `PodsFieldSource` + Schema-Wahl über `usable(PodsFeature::…)`; `isAvailable()` entfällt; Feldrechte über `usable(FieldPermissions)`, sonst fail-closed; 0.2-Bedingung = `active && ! usable(SchemaRead)` | `PodsFieldSourceTest` je Fähigkeit; `PluginTest` | alle Pods-Tests grün | sonnet | | 1.14 | `PodsFieldSource` + Schema-Wahl über `usable(PodsFeature::…)`; `isAvailable()` entfällt; Feldrechte über `usable(FieldPermissions)`, sonst fail-closed; 0.2-Bedingung = `active && ! usable(SchemaRead)` | `PodsFieldSourceTest` je Fähigkeit; `PluginTest` | alle Pods-Tests grün | sonnet |
| 1.15 | `CompatibilityTab` + `CompatibilitySection` (Filter `lit_mcp_compat_sections`, lazy); Zustand (Wort + Icon) und Probleme je Fähigkeit für Nicht-`ReleasePerVersion`-Profile | `CompatibilityTabTest`: drei Zustände, Sektion eingebettet, escaped | `CoreIndependenceTest` grün | sonnet | **Erledigt 1.1–1.14:** 9beb55e, b511ef6, c851316.
| 1.16 | `DiviTab implements CompatibilitySection` (`title()` „Divi“); Slug/Redirects/`requested()`/Notice auf `compatibility`; `DiviIntegration` hängt über `lit_mcp_compat_sections` ein | `DiviTabTest`, `DiviIntegrationTest` | Release/Withdraw/Orphans/Render unverändert | sonnet |
| 1.17 | Links/Texte: `DiviCheckRoute`, `ReleaseStatus:24`, `docs/guides/overview.md:96`, `divi-release-smoke.sh`, Docblock `DiviCheckCommand` | `DiviCheckRouteTest` `tab=compatibility`; `ReleaseStatusTest` „→ Compatibility“ | DDEV-Smoke grün | haiku | **Neu-Zuschnitt 1.15–1.25 (Gate 1 vom 2026-11-06):** Prüfen und Freigeben sind für alle Profile im Reiter „Compatibility“
| 1.18 | Reiter in `Plugin::boot` registrieren (nach `LogTab`); Pods-Smoke erweitern | `PluginTest` Tab-Liste | DDEV: `compatibility ok`, Pods ohne Probleme; hohe minVersion → Pods-Felder schreibgeschützt | sonnet | gleich; die Wirkung regelt das Tier (ReleasePerVersion = Pflicht, LockOnFailure = dokumentiert, Nutzung automatisch solange grün,
ReportOnly = nur Status). Optionen `lit_mcp_<id>_releases|_check_report|_check_lock` – für `divi` exakt die heutigen Namen.
**Divi behält auch das Datenformat:** Store und Bericht bekommen den Versionsschlüssel als Parameter (Divi `divi_version`, neue
Profile `version`) – keine Migration, Rollback-sicher. Profil-ID aus POST nur nach Allowlist (`Compatibility::profile()`) in
Optionsnamen.
| 1.15 | `Profile`: `testedVersions`, `stateKey`, `extraSteps` (Closure(bool $manifestClean): list<CheckStep>), `manifestCheck`; `DiviProfile` + `active`, `testedVersions=DiviReleases::TESTED`, `stateKey=ReleaseResolver::stateKey`; Pods `testedVersions=['3.3.9.2']` | `ProfileTest`, `DiviProfileTest`, `PodsCompatibilityTest` | Gates grün | sonnet (mit 1.16–1.18) |
| 1.16 | `CheckStep`, `CheckReport`, `CheckReportStore(profileId, versionKey)` nach `src/Compatibility`; Label mitgespeichert; Divi-Schrittkonstanten in `DiviCheckSteps` | `CheckReportTest` (Divi-Rundlauf unverändert, Option je ID) | Divi-Suite grün | sonnet |
| 1.17 | `ReleaseStore(profileId, versionKey)` nach `src/Compatibility` | `ReleaseStoreTest` (Divi-Format unverändert, Option je ID) | `ReleaseResolverTest` grün | sonnet |
| 1.18 | `CompatibilityCheck` generisch (Manifest, Fingerabdrücke nur mit Mirrors, dann `extraSteps`); `Compatibility::check(Profile)`; Divi liefert Katalog/Format/Schreibtest als `extraSteps` | `CompatibilityCheckTest`, `DiviCheckStepsTest` | `DiviCheckCommandTest` grün, Divi-Schrittfolge gleich | sonnet |
| 1.18a | Review-Befunde (Architektur-Review 2026-11-06): roter gespeicherter Bericht für den aktuellen Stand sperrt LockOnFailure-Fähigkeiten bis zur nächsten grünen Prüfung; gefangene Ausnahmen in `Compatibility` per `Log::failure` protokollieren; `Profile` nur mit benannten Argumenten bauen | `CompatibilityTest` (roter/grüner/veralteter/kein Bericht) | Divi unverändert | sonnet |
| 1.19 | `Admin\CheckRoute` je Profil (`/<id>-check`, Lock je Profil, Redirect `tab=compatibility`, `do_action('lit_mcp_compat_changed', $id)`; Divi invalidiert darauf `ReleaseGate`) | `CheckRouteTest`, `DiviIntegrationTest` | Lock-Tests portiert | sonnet |
| 1.20 | `Admin\CompatibilityTab` read-only (Sektion je Profil, Zustand Wort+Icon, Tier-Hinweis, Fähigkeiten, Versionen, Bericht, Hook `lit_mcp_compat_profile_extra`; inaktiv ohne `matrix()`) | `CompatibilityTabTest` inkl. Escaping | `CoreIndependenceTest` grün | sonnet |
| 1.21 | Formulare + admin-post `lit_mcp_compat_release|withdraw` (guard, Allowlist, Nonce je Profil) | `CompatibilityTabTest` (frischer Bericht, unbekanntes Profil, Nonce/403, Tier-Sichtbarkeit) | Release/Withdraw-Fälle aus `DiviTabTest` portiert | sonnet |
| 1.22 | Reiter in `Plugin::boot` (nach `LimitsTab`), Hilfe-Tab | `PluginTest`, `SettingsPageTest` | grün | sonnet |
| 1.23 | `DiviTab` → `DiviAdminExtras` (Notice, Orphans, Zusatzhinweise); `TABS_FILTER`-Eintrag entfällt | `DiviAdminExtrasTest`, `DiviIntegrationTest` | Boundary/Core grün | sonnet |
| 1.24 | Texte/CSS (`lit-mcp-divi-*` → `lit-mcp-compat-*`), `ReleaseStatus:24`, `docs/guides/overview.md`, `Uninstaller` (`lit_mcp_pods_*`), `divi-release-smoke.sh` | `ReleaseStatusTest`, `UninstallerTest` | DDEV Divi-Smoke grün | sonnet |
| 1.25 | Pods-Smoke: `/pods-check`, Freigabe schreibt `lit_mcp_pods_releases` ohne Sperrwirkung, hohe `minVersion` sperrt | Smoke rot ohne 1.19–1.21 | DDEV Pods- und Divi-Smoke grün | sonnet |
## Phase 2 – WP Super Cache 3.1.5 ## Phase 2 – WP Super Cache 3.1.5
@@ -67,8 +84,9 @@ mechanisch (Namespace + `use`), keine Verhaltensänderung. Keine Commits durch U
(1.2–1.4) · 6 `refactor(compat): CompatibilityMatrix generisch mit fromProblems` (1.5) · 7 `refactor(compat): Manifest und (1.2–1.4) · 6 `refactor(compat): CompatibilityMatrix generisch mit fromProblems` (1.5) · 7 `refactor(compat): Manifest und
ManifestCheck generisch, DiviAreas, DiviManifestCheck` (1.6–1.7) · 8 `feat(compat): Tier, Profile, Compatibility, ManifestCheck generisch, DiviAreas, DiviManifestCheck` (1.6–1.7) · 8 `feat(compat): Tier, Profile, Compatibility,
CapabilityLocked, CapabilityGate` (1.8–1.10) · 9 `feat(compat): Profile aus Integrationen, Divi- und Pods-Profil` (1.11–1.12) · CapabilityLocked, CapabilityGate` (1.8–1.10) · 9 `feat(compat): Profile aus Integrationen, Divi- und Pods-Profil` (1.11–1.12) ·
10 `feat(compat): Gates, Status und Pods über den Kern verdrahten` (1.13–1.14) · 11 `feat(admin): Reiter „Compatibility“ mit 10 `feat(compat): Gates, Status und Pods über den Kern verdrahten` (1.13–1.14) · 11a `refactor(compat): Freigabe-Bausteine
Divi als Abschnitt` (1.15–1.18) · 12 `feat(changelog): Hook lit_mcp_changed` (2.1) · 13 `feat(cache): WP Super Cache über den generisch` (1.15–1.18) · 11b `feat(compat): Prüfroute je Profil` (1.19) · 11c `feat(admin): Reiter „Compatibility“ für alle
Profile` (1.20–1.22) · 11d `refactor(divi): DiviTab geht im Reiter auf` (1.23–1.24) · 11e `test(compat): Smokes` (1.25) · 12 `feat(changelog): Hook lit_mcp_changed` (2.1) · 13 `feat(cache): WP Super Cache über den
Kern anbinden` (2.2–2.4) · 14 `test(cache): Integrations-Smoke` (2.5) Kern anbinden` (2.2–2.4) · 14 `test(cache): Integrations-Smoke` (2.5)
## Risiken ## Risiken
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -14,7 +14,7 @@ final class CheckReport
public readonly string $id, public readonly string $id,
public readonly string $createdAt, public readonly string $createdAt,
public readonly int $userId, public readonly int $userId,
public readonly string $diviVersion, public readonly string $version,
public readonly string $pluginVersion, public readonly string $pluginVersion,
public readonly string $stateKey, public readonly string $stateKey,
public readonly array $steps public readonly array $steps
@@ -35,11 +35,16 @@ final class CheckReport
return true; return true;
} }
/** Green and made for exactly this Divi version, plugin version and Divi files (ReleaseResolver::stateKey()). */ /** Green and made for exactly this version, plugin version and state (Profile::$stateKey). */
public function freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool public function freshFor(string $version, string $pluginVersion, string $stateKey): bool
{ {
return $this->green() return $this->green() && $this->currentFor($version, $pluginVersion, $stateKey);
&& $this->diviVersion === $diviVersion }
/** Made for exactly this state, green or not. */
public function currentFor(string $version, string $pluginVersion, string $stateKey): bool
{
return $this->version === $version
&& $this->pluginVersion === $pluginVersion && $this->pluginVersion === $pluginVersion
&& $this->stateKey === $stateKey; && $this->stateKey === $stateKey;
} }
@@ -55,26 +60,29 @@ final class CheckReport
return null; return null;
} }
/** @return array<string, mixed> */ /**
public function toArray(): array * @param string $versionKey Array key of the version; Divi keeps 'divi_version' so stored reports stay as they are.
* @return array<string, mixed>
*/
public function toArray(string $versionKey = 'version'): array
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'created_at' => $this->createdAt, 'created_at' => $this->createdAt,
'user_id' => $this->userId, 'user_id' => $this->userId,
'divi_version' => $this->diviVersion, $versionKey => $this->version,
'plugin_version' => $this->pluginVersion, 'plugin_version' => $this->pluginVersion,
'state_key' => $this->stateKey, 'state_key' => $this->stateKey,
'steps' => array_map(static fn (CheckStep $step): array => $step->toArray(), $this->steps), 'steps' => array_map(static fn (CheckStep $step): array => $step->toArray(), $this->steps),
]; ];
} }
public static function fromArray(mixed $data): ?self public static function fromArray(mixed $data, string $versionKey = 'version'): ?self
{ {
if (! is_array($data)) { if (! is_array($data)) {
return null; return null;
} }
foreach (['id', 'created_at', 'divi_version', 'plugin_version', 'state_key'] as $key) { foreach (['id', 'created_at', $versionKey, 'plugin_version', 'state_key'] as $key) {
if (! is_string($data[$key] ?? null)) { if (! is_string($data[$key] ?? null)) {
return null; return null;
} }
@@ -96,7 +104,7 @@ final class CheckReport
$data['id'], $data['id'],
$data['created_at'], $data['created_at'],
$data['user_id'], $data['user_id'],
$data['divi_version'], $data[$versionKey],
$data['plugin_version'], $data['plugin_version'],
$data['state_key'], $data['state_key'],
$steps $steps
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/**
* The latest admin check report of one profile (spec §6.3), option lit_mcp_<profile>_check_report. Not final so the
* tab can be tested with a mock.
*/
class CheckReportStore
{
/** @param string $versionKey Array key of the version in the stored report (Divi: 'divi_version'). */
public function __construct(private string $profileId, private string $versionKey = 'version')
{
}
public static function forProfile(Profile $profile): self
{
return new self($profile->id, $profile->reportVersionKey);
}
public function latest(): ?CheckReport
{
return CheckReport::fromArray(get_option($this->option(), null), $this->versionKey);
}
public function save(CheckReport $report): void
{
update_option($this->option(), $report->toArray($this->versionKey), false);
}
private function option(): string
{
return 'lit_mcp_' . $this->profileId . '_check_report';
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/**
* One step of the admin check (spec §6.2); skipped = not run. The label is stored with the step, so a report stays
* readable without the profile that made it; an old report without one shows the raw key.
*/
final class CheckStep
{
public const MANIFEST = 'manifest';
public const MIRRORS = 'mirrors';
/** The label of a generic step of the profile named $profileLabel; null for any other key. */
public static function genericLabel(string $key, string $profileLabel): ?string
{
return match ($key) {
self::MANIFEST => sprintf('%s-Funktionen, Klassen und Registrierungen', $profileLabel),
self::MIRRORS => sprintf('Fingerabdrücke nachgebauter %s-Funktionen', $profileLabel),
default => null,
};
}
/** @param list<string> $problems */
public function __construct(
public readonly string $key,
public readonly array $problems,
public readonly bool $skipped = false,
public readonly ?string $label = null
) {
}
public function green(): bool
{
return ! $this->skipped && $this->problems === [];
}
public function label(): string
{
return $this->label ?? $this->key;
}
/** @return array{key: string, problems: list<string>, skipped: bool, label?: string} */
public function toArray(): array
{
$data = ['key' => $this->key, 'problems' => $this->problems, 'skipped' => $this->skipped];
return $this->label === null ? $data : $data + ['label' => $this->label];
}
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,
is_string($data['label'] ?? null) ? $data['label'] : null
);
}
}
+81 -14
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace LIT\MCP\Compatibility; namespace LIT\MCP\Compatibility;
use Closure; use Closure;
use LIT\MCP\Log;
use LIT\MCP\Plugin;
use LogicException; use LogicException;
use Throwable; use Throwable;
@@ -21,25 +23,31 @@ final class Compatibility
private array $byId = []; private array $byId = [];
/** @var array<string, array{0: Capability, 1: Profile}> keyed by capability ID */ /** @var array<string, array{0: Capability, 1: Profile}> keyed by capability ID */
private array $byCapability = []; private array $byCapability = [];
/** @var array<string, bool> */
private array $actives = [];
/** @var array<string, string|null> */
private array $versions = [];
/** @var array<string, CompatibilityMatrix> */ /** @var array<string, CompatibilityMatrix> */
private array $matrices = []; private array $matrices = [];
/** @var array<string, ManifestCheck> */ /** @var array<string, ManifestCheck> */
private array $checks = []; private array $checks = [];
/** @var Closure(Profile): ManifestCheck */ /** @var Closure(Profile): ManifestCheck */
private Closure $check; private Closure $check;
/** @var Closure(Profile): ?CheckReport */
private Closure $report;
/** /**
* @param list<Profile> $profiles * @param list<Profile> $profiles
* @param (Closure(Profile): ManifestCheck)|null $check Builds the check of a profile; default: its manifests * @param (Closure(Profile): ManifestCheck)|null $check Builds the check of a profile; default: Profile::checker().
* without signature checks. * @param (Closure(Profile): ?CheckReport)|null $report The latest check report of a profile; a red one for
* the current state locks a LockOnFailure profile.
* Default: none (the structure check is the base).
* @throws LogicException For a profile ID or a capability ID that occurs twice. * @throws LogicException For a profile ID or a capability ID that occurs twice.
*/ */
public function __construct(private array $profiles, ?Closure $check = null) public function __construct(private array $profiles, ?Closure $check = null, ?Closure $report = null)
{ {
$this->check = $check ?? static fn (Profile $profile): ManifestCheck => new ManifestCheck( $this->report = $report ?? static fn (Profile $profile): ?CheckReport => null;
$profile->manifests, $this->check = $check ?? static fn (Profile $profile): ManifestCheck => $profile->checker();
signatures: false
);
foreach ($profiles as $profile) { foreach ($profiles as $profile) {
if (isset($this->byId[$profile->id])) { if (isset($this->byId[$profile->id])) {
throw new LogicException('profile ID twice: ' . $profile->id); throw new LogicException('profile ID twice: ' . $profile->id);
@@ -77,14 +85,18 @@ final class Compatibility
public function active(Profile $profile): bool public function active(Profile $profile): bool
{ {
return $profile->active === null || self::attempt($profile->active) === true; return $this->actives[$profile->id] ??= $profile->active === null
|| self::attempt($profile->active, $profile) === true;
} }
public function version(Profile $profile): ?string public function version(Profile $profile): ?string
{ {
$version = $profile->version === null ? null : self::attempt($profile->version); if (! array_key_exists($profile->id, $this->versions)) {
$version = $profile->version === null ? null : self::attempt($profile->version, $profile);
$this->versions[$profile->id] = is_string($version) ? $version : null;
}
return is_string($version) ? $version : null; return $this->versions[$profile->id];
} }
public function matrix(Profile $profile): CompatibilityMatrix public function matrix(Profile $profile): CompatibilityMatrix
@@ -104,11 +116,19 @@ final class Compatibility
static fn (Problem $problem): string => $problem->label, static fn (Problem $problem): string => $problem->label,
$this->checkOf($profile)->problems() $this->checkOf($profile)->problems()
)); ));
} catch (Throwable) { } catch (Throwable $e) {
Log::failure('compat ' . $profile->id, $e);
return [self::failed($profile)]; return [self::failed($profile)];
} }
} }
/** Runs the admin check of the profile (spec §6.2); a report for $userId. */
public function check(Profile $profile, int $userId): CheckReport
{
return CompatibilityCheck::forProfile($profile, $this->checkOf($profile))->run($userId);
}
public function usable(Capability $capability): bool public function usable(Capability $capability): bool
{ {
$owner = $this->owner($capability); $owner = $this->owner($capability);
@@ -161,17 +181,62 @@ final class Compatibility
return CompatibilityMatrix::allIncompatible($capabilities, $tooOld); return CompatibilityMatrix::allIncompatible($capabilities, $tooOld);
} }
$red = $this->redReport($profile);
if ($red !== null) {
// shortcut: a step names no capability, so a red check locks all of them; refine with step features.
return CompatibilityMatrix::allIncompatible($capabilities, $red);
}
return CompatibilityMatrix::fromProblems( return CompatibilityMatrix::fromProblems(
$capabilities, $capabilities,
$this->checkOf($profile)->problems(), $this->checkOf($profile)->problems(),
null, null,
automatic: true automatic: true
); );
} catch (Throwable) { } catch (Throwable $e) {
Log::failure('compat ' . $profile->id, $e);
return CompatibilityMatrix::allIncompatible($capabilities, self::failed($profile)); return CompatibilityMatrix::allIncompatible($capabilities, self::failed($profile));
} }
} }
/**
* What the latest report says when it is red and made for the current state; null = no reason to lock. A report
* without steps is no check result. The reason names only the labels of the steps, never their problem texts
* (they reach MCP clients in the 423). Only a new green check or a version change (plugin or third-party plugin)
* lifts a lock by a red report.
*/
private function redReport(Profile $profile): ?string
{
$report = ($this->report)($profile);
$version = $this->version($profile) ?? '';
$stateKey = $profile->stateKey === null ? '' : ($profile->stateKey)($version);
if (
$report === null
|| $report->steps === []
|| $report->green()
|| ! $report->currentFor($version, Plugin::VERSION, $stateKey)
) {
return null;
}
$failed = array_map(
static fn (CheckStep $step): string => $step->label(),
array_values(array_filter($report->steps, static fn (CheckStep $step): bool => ! $step->green()))
);
return sprintf(
'Die letzte Prüfung von %s ist fehlgeschlagen (%s); Details im Admin-Bereich.',
$profile->label,
implode('; ', $failed)
);
}
/** The report stored by the admin check, for Compatibility's $report parameter. */
public static function storedReport(Profile $profile): ?CheckReport
{
return CheckReportStore::forProfile($profile)->latest();
}
private function versionProblem(Profile $profile): ?string private function versionProblem(Profile $profile): ?string
{ {
if ($profile->minVersion === null) { if ($profile->minVersion === null) {
@@ -202,11 +267,13 @@ final class Compatibility
} }
/** The closure's result, null when it throws. */ /** The closure's result, null when it throws. */
private static function attempt(Closure $closure): mixed private static function attempt(Closure $closure, Profile $profile): mixed
{ {
try { try {
return $closure(); return $closure();
} catch (Throwable) { } catch (Throwable $e) {
Log::failure('compat ' . $profile->id, $e);
return null; return null;
} }
} }
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
use LIT\MCP\Log;
use LIT\MCP\Plugin;
use Throwable;
defined('ABSPATH') || exit;
/**
* The admin check of one profile (spec §6.2) – also wp lit-mcp divi-check: manifest (code and registrations),
* fingerprints (only when a manifest has mirrors), then the extra steps of the profile. A step that throws is a red
* step. Not final so the route and the command can be tested with a mock.
*/
class CompatibilityCheck
{
public function __construct(private Profile $profile, private ManifestCheck $manifests)
{
}
public static function forProfile(Profile $profile, ?ManifestCheck $check = null): self
{
return new self($profile, $check ?? $profile->checker());
}
public function run(int $userId): CheckReport
{
$label = $this->profile->label;
$manifest = self::guarded(fn (): array => ManifestCheck::failed(
array_merge($this->manifests->codeChecks(), $this->manifests->registrationChecks())
));
$steps = [new CheckStep(
CheckStep::MANIFEST,
$manifest,
false,
CheckStep::genericLabel(CheckStep::MANIFEST, $label)
)];
if (array_filter($this->profile->manifests, static fn (Manifest $m): bool => $m->mirrors !== []) !== []) {
$steps[] = new CheckStep(
CheckStep::MIRRORS,
self::guarded(fn (): array => ManifestCheck::failed($this->manifests->mirrorChecks())),
false,
CheckStep::genericLabel(CheckStep::MIRRORS, $label)
);
}
if ($this->profile->extraSteps !== null) {
$steps = [...$steps, ...($this->profile->extraSteps)($manifest === [])];
}
$version = (string) ($this->profile->version === null ? '' : ($this->profile->version)());
return new CheckReport(
wp_generate_uuid4(),
gmdate('c'),
$userId,
$version,
Plugin::VERSION,
$this->profile->stateKey === null ? '' : ($this->profile->stateKey)($version),
$steps
);
}
/** @return list<string> The step's problems; a throwing step is one problem. */
public static function guarded(Closure $step): array
{
try {
return array_values(array_filter((array) $step(), 'is_string'));
} catch (Throwable $e) {
// The report reaches MCP clients through the lock reason; the exception text stays in the log.
Log::failure('compat check step', $e);
return ['Prüfschritt abgebrochen (Details im PHP-Error-Log).'];
}
}
}
+23 -1
View File
@@ -12,6 +12,7 @@ defined('ABSPATH') || exit;
/** /**
* Everything the compatibility layer needs to know about one third-party plugin: its capabilities, what its code * Everything the compatibility layer needs to know about one third-party plugin: its capabilities, what its code
* depends on, how to tell whether it is active and which version it has. Pure data; Compatibility evaluates it. * depends on, how to tell whether it is active and which version it has. Pure data; Compatibility evaluates it.
* Mit benannten Argumenten bauen.
*/ */
final class Profile final class Profile
{ {
@@ -22,6 +23,14 @@ final class Profile
* @param (Closure(): ?string)|null $version The installed version, null result = unknown. * @param (Closure(): ?string)|null $version The installed version, null result = unknown.
* @param string|null $minVersion Lowest supported version (version_compare). * @param string|null $minVersion Lowest supported version (version_compare).
* @param (Closure(): CompatibilityMatrix)|null $matrix Required for ReleasePerVersion, unused otherwise. * @param (Closure(): CompatibilityMatrix)|null $matrix Required for ReleasePerVersion, unused otherwise.
* @param list<string> $testedVersions Versions the capabilities were verified against.
* @param (Closure(string): string)|null $stateKey What a check report is valid for, given the version.
* @param (Closure(bool): list<CheckStep>)|null $extraSteps Steps after manifest and fingerprints; gets
* whether the manifest step was clean.
* @param (Closure(): ManifestCheck)|null $manifestCheck The check of this profile; null = its manifests
* without signature checks.
* @param string $reportVersionKey Array key of the version in stored reports and
* release rows (Divi: 'divi_version').
* @throws LogicException For a ReleasePerVersion profile without matrix or a capability ID given twice. * @throws LogicException For a ReleasePerVersion profile without matrix or a capability ID given twice.
*/ */
public function __construct( public function __construct(
@@ -33,7 +42,12 @@ final class Profile
public readonly ?Closure $active = null, public readonly ?Closure $active = null,
public readonly ?Closure $version = null, public readonly ?Closure $version = null,
public readonly ?string $minVersion = null, public readonly ?string $minVersion = null,
public readonly ?Closure $matrix = null public readonly ?Closure $matrix = null,
public readonly array $testedVersions = [],
public readonly ?Closure $stateKey = null,
public readonly ?Closure $extraSteps = null,
public readonly ?Closure $manifestCheck = null,
public readonly string $reportVersionKey = 'version'
) { ) {
if ($tier === Tier::ReleasePerVersion && $matrix === null) { if ($tier === Tier::ReleasePerVersion && $matrix === null) {
throw new LogicException(sprintf('profile %s: a ReleasePerVersion profile needs a matrix closure', $id)); throw new LogicException(sprintf('profile %s: a ReleasePerVersion profile needs a matrix closure', $id));
@@ -45,4 +59,12 @@ final class Profile
throw new LogicException(sprintf('profile %s: capability IDs twice: %s', $id, implode(', ', $duplicates))); throw new LogicException(sprintf('profile %s: capability IDs twice: %s', $id, implode(', ', $duplicates)));
} }
} }
/** The check of this profile: its manifestCheck closure, else its manifests without signature checks. */
public function checker(): ManifestCheck
{
return $this->manifestCheck !== null
? ($this->manifestCheck)()
: new ManifestCheck($this->manifests, signatures: false);
}
} }
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/**
* Versions of one profile released on this site after a green admin check (spec §5.3, source "database"), option
* lit_mcp_<profile>_releases. An entry holds for the plugin version that ran the check only.
*
* Not final so the resolver can be tested with a mock.
*/
class ReleaseStore
{
/** @param string $versionKey Row key of the version (Divi: 'divi_version', so stored rows stay as they are). */
public function __construct(private string $profileId, private string $versionKey = 'version')
{
}
public static function forProfile(Profile $profile): self
{
return new self($profile->id, $profile->reportVersionKey);
}
/**
* @return list<array<string, string|int>> Rows with the keys <versionKey>, plugin_version, user_id, released_at,
* report_id.
*/
public function entries(): array
{
$raw = get_option($this->option(), []);
$entries = [];
foreach (is_array($raw) ? $raw : [] as $row) {
if (
is_array($row)
&& is_string($row[$this->versionKey] ?? null)
&& is_string($row['plugin_version'] ?? null)
&& is_int($row['user_id'] ?? null)
&& is_string($row['released_at'] ?? null)
&& is_string($row['report_id'] ?? null)
) {
$entries[] = [
$this->versionKey => $row[$this->versionKey],
'plugin_version' => $row['plugin_version'],
'user_id' => $row['user_id'],
'released_at' => $row['released_at'],
'report_id' => $row['report_id'],
];
}
}
return $entries;
}
/**
* @return array<string, string|int>|null
*/
public function find(string $version, string $pluginVersion): ?array
{
foreach ($this->entries() as $entry) {
if ($entry[$this->versionKey] === $version && $entry['plugin_version'] === $pluginVersion) {
return $entry;
}
}
return null;
}
public function add(string $version, string $pluginVersion, int $userId, string $reportId): void
{
$entries = $this->without($version, $pluginVersion);
$entries[] = [
$this->versionKey => $version,
'plugin_version' => $pluginVersion,
'user_id' => $userId,
'released_at' => gmdate('c'),
'report_id' => $reportId,
];
update_option($this->option(), $entries, false);
}
public function withdraw(string $version, string $pluginVersion): bool
{
if ($this->find($version, $pluginVersion) === null) {
return false;
}
update_option($this->option(), $this->without($version, $pluginVersion), false);
return true;
}
/**
* @return list<array<string, string|int>> Rows with the keys <versionKey>, plugin_version, user_id, released_at,
* report_id.
*/
private function without(string $version, string $pluginVersion): array
{
return array_values(array_filter(
$this->entries(),
fn (array $entry): bool => $entry[$this->versionKey] !== $version
|| $entry['plugin_version'] !== $pluginVersion
));
}
private function option(): string
{
return 'lit_mcp_' . $this->profileId . '_releases';
}
}
+15 -8
View File
@@ -10,16 +10,18 @@ use LIT\MCP\Admin\AdminTab;
use LIT\MCP\Admin\SettingsPage; use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\ReleaseState; use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\CheckReport; use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore; use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview; use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Compatibility\FeatureStatus; use LIT\MCP\Compatibility\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore; use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Integrations\Divi\Release\WriteProbe; use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin; use LIT\MCP\Plugin;
@@ -159,7 +161,7 @@ final class DiviTab implements AdminTab
return; return;
} }
($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id); $this->releases()->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
ReleaseGate::invalidate(); ReleaseGate::invalidate();
$this->log?->record( $this->log?->record(
'lit/divi-release', 'lit/divi-release',
@@ -177,7 +179,7 @@ final class DiviTab implements AdminTab
check_admin_referer(self::WITHDRAW_ACTION); check_admin_referer(self::WITHDRAW_ACTION);
$version = $this->version(); $version = $this->version();
if (! ($this->releases ?? new ReleaseStore())->withdraw($version, Plugin::VERSION)) { if (! $this->releases()->withdraw($version, Plugin::VERSION)) {
$action->redirect(self::SLUG, 'divi_withdraw_failed'); $action->redirect(self::SLUG, 'divi_withdraw_failed');
return; return;
@@ -514,7 +516,7 @@ final class DiviTab implements AdminTab
esc_html(sprintf( esc_html(sprintf(
/* translators: 1: Divi version, 2: plugin version, 3: date */ /* translators: 1: Divi version, 2: plugin version, 3: date */
__('Divi %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'), __('Divi %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'),
$report->diviVersion, $report->version,
$report->pluginVersion, $report->pluginVersion,
date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt)) date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt))
)) ))
@@ -530,7 +532,7 @@ final class DiviTab implements AdminTab
. ' aria-hidden="true"></span><span class="screen-reader-text">%2$s:</span> %3$s', . ' aria-hidden="true"></span><span class="screen-reader-text">%2$s:</span> %3$s',
esc_attr($icon), esc_attr($icon),
esc_html($word), esc_html($word),
esc_html($step->label()) esc_html(DiviCheckSteps::label($step))
); );
if ($step->skipped) { if ($step->skipped) {
echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')'; echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')';
@@ -569,8 +571,13 @@ final class DiviTab implements AdminTab
return ($this->stateKey ?? static fn (string $v): string => ReleaseResolver::stateKey($v))($version); return ($this->stateKey ?? static fn (string $v): string => ReleaseResolver::stateKey($v))($version);
} }
private function releases(): ReleaseStore
{
return $this->releases ?? new ReleaseStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
}
private function reports(): CheckReportStore private function reports(): CheckReportStore
{ {
return $this->reports ?? new CheckReportStore(); return $this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
} }
} }
@@ -7,10 +7,12 @@ namespace LIT\MCP\Integrations\Divi\Cli;
use Closure; use Closure;
use LIT\MCP\Compatibility\LineDiff; use LIT\MCP\Compatibility\LineDiff;
use LIT\MCP\Compatibility\SourceFingerprint; use LIT\MCP\Compatibility\SourceFingerprint;
use LIT\MCP\Integrations\Divi\Release\CheckReport; use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore; use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep; use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Compatibility\CompatibilityMatrix; use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters; use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
@@ -109,10 +111,10 @@ final class DiviCheckCommand
} }
$report = ($this->check)()->run(get_current_user_id()); $report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report); ($this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY))->save($report);
($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->diviVersion ?: '?', $report->pluginVersion)); ($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->version ?: '?', $report->pluginVersion));
foreach ($report->steps as $step) { foreach ($report->steps as $step) {
($this->out)(($step->skipped ? '–' : ($step->green() ? '✓' : '✗')) . ' ' . $step->label()); ($this->out)(($step->skipped ? '–' : ($step->green() ? '✓' : '✗')) . ' ' . DiviCheckSteps::label($step));
foreach ($step->problems as $problem) { foreach ($step->problems as $problem) {
($this->out)(' ' . $problem); ($this->out)(' ' . $problem);
} }
+11 -3
View File
@@ -9,6 +9,8 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolAvailability; use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\SettingsPage; use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ProvidesCompatibility; use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Bootable; use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Divi\Admin\DiviTab; use LIT\MCP\Integrations\Divi\Admin\DiviTab;
@@ -73,7 +75,6 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview; use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\DiviProfile; use LIT\MCP\Integrations\Divi\Release\DiviProfile;
@@ -190,7 +191,7 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil
public function compatibilities(): array public function compatibilities(): array
{ {
return [DiviProfile::profile()]; return [$this->profile()];
} }
/** /**
@@ -261,7 +262,8 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil
// Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no // Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no
// Tools-tab gate: the check tests Divi, not this site's tool switches. // Tools-tab gate: the check tests Divi, not this site's tool switches.
$check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->shortForm(null)); $profile = $this->profile();
$check = static fn (): CompatibilityCheck => CompatibilityCheck::forProfile($profile);
(new DiviCheckRoute($check))->register(); (new DiviCheckRoute($check))->register();
DiviCheckCommand::register($check); DiviCheckCommand::register($check);
@@ -429,6 +431,12 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil
} }
/** @param (Closure(): bool)|null $writable */ /** @param (Closure(): bool)|null $writable */
/** The Divi profile; the check's write test composes through the short form without a Tools-tab gate. */
private function profile(): Profile
{
return DiviProfile::profile(fn (): DiviContentComposer => $this->shortForm(null));
}
private function shortForm(?Closure $writable): DiviContentComposer private function shortForm(?Closure $writable): DiviContentComposer
{ {
return new DiviContentComposer($this->schema, $this->composition(), new DiviSaver(), $this->design, $writable); return new DiviContentComposer($this->schema, $this->composition(), new DiviSaver(), $this->design, $writable);
@@ -1,23 +0,0 @@
<?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);
}
}
@@ -1,62 +0,0 @@
<?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
);
}
}
@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Compatibility\ManifestCheck;
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(
DiviManifestCheck::create(),
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()];
}
}
}
@@ -6,6 +6,8 @@ namespace LIT\MCP\Integrations\Divi\Release;
use Closure; use Closure;
use LIT\MCP\Admin\SettingsPage; use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Admin\DiviTab; use LIT\MCP\Integrations\Divi\Admin\DiviTab;
use LIT\MCP\Log; use LIT\MCP\Log;
use Throwable; use Throwable;
@@ -62,7 +64,8 @@ final class DiviCheckRoute
if (self::lock()) { if (self::lock()) {
try { try {
$report = ($this->check)()->run(get_current_user_id()); $report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report); $reports = $this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
$reports->save($report);
ReleaseGate::invalidate(); ReleaseGate::invalidate();
$notice = 'divi_checked'; $notice = 'divi_checked';
} catch (Throwable $e) { } catch (Throwable $e) {
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\CompatibilityCheck;
defined('ABSPATH') || exit;
/** The check steps only Divi has (module catalog, format round trip, write test) and their labels. */
final class DiviCheckSteps
{
public const CATALOG = 'catalog';
public const FORMAT = 'format';
public const WRITE = 'write';
/** Labels of the Divi-only steps; manifest and mirrors come from CheckStep::genericLabel(). */
private const LABELS = [
self::CATALOG => 'Modulkatalog (Kurzform-Pfade)',
self::FORMAT => 'Format im Speicher (Parser und Serializer)',
self::WRITE => 'Schreibtest auf einer Wegwerfseite',
];
/** @param list<string> $problems */
public static function step(string $key, array $problems, bool $skipped = false): CheckStep
{
return new CheckStep($key, $problems, $skipped, self::LABELS[$key] ?? null);
}
/**
* The Profile::$extraSteps of Divi: module catalog, format round trip and the write test. 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.
*
* @param Closure(): list<string> $catalog Short-form problems against Divi's live catalog.
* @param Closure(): list<string> $probe WriteProbe::run().
* @return Closure(bool): list<CheckStep>
*/
public static function extra(Closure $catalog, FormatCheck $format, Closure $probe): Closure
{
return static fn (bool $manifestClean): array => [
self::step(self::CATALOG, CompatibilityCheck::guarded($catalog)),
self::step(self::FORMAT, CompatibilityCheck::guarded(static fn (): array => $format->problems())),
$manifestClean
? self::step(self::WRITE, CompatibilityCheck::guarded($probe))
: self::step(self::WRITE, [], true),
];
}
public static function label(CheckStep $step): string
{
// Reports saved before labels were stored have none.
return $step->label
?? self::LABELS[$step->key]
?? CheckStep::genericLabel($step->key, DiviProfile::LABEL)
?? $step->key;
}
}
+32 -7
View File
@@ -4,24 +4,49 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Compatibility\Profile; use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier; use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LogicException;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** Divi as a compatibility profile: every version needs a release, the matrix comes from the release gate. */ /** Divi as a compatibility profile: every version needs a release, the matrix comes from the release gate. */
final class DiviProfile final class DiviProfile
{ {
public static function profile(): Profile public const ID = 'divi';
public const LABEL = 'Divi';
/** Stored reports and release rows keep this key, so data from before the generic layer stays readable. */
public const REPORT_VERSION_KEY = 'divi_version';
/** @param (Closure(): ContentComposer)|null $composer The write test's composer; without it that step fails. */
public static function profile(?Closure $composer = null): Profile
{ {
return new Profile( return new Profile(
'divi', id: self::ID,
'Divi', label: self::LABEL,
Tier::ReleasePerVersion, tier: Tier::ReleasePerVersion,
DiviFeature::cases(), capabilities: DiviFeature::cases(),
array_values(DiviAdapters::manifests()), manifests: array_values(DiviAdapters::manifests()),
active: static fn (): bool => DiviEnvironment::version() !== null,
version: DiviEnvironment::version(...), version: DiviEnvironment::version(...),
matrix: static fn () => ReleaseGate::status()->matrix matrix: static fn () => ReleaseGate::status()->matrix,
testedVersions: DiviReleases::TESTED,
stateKey: ReleaseResolver::stateKey(...),
extraSteps: DiviCheckSteps::extra(
static fn (): array => DiviModules::isAvailable()
? ShortformCheck::problems((new DiviModules())->catalog())
: ['Divi 5 ist in dieser Anfrage nicht geladen.'],
new FormatCheck(),
static fn (): array => (new WriteProbe(
$composer === null ? throw new LogicException('no composer for the write test') : $composer()
))->run()
),
manifestCheck: DiviManifestCheck::create(...),
reportVersionKey: self::REPORT_VERSION_KEY
); );
} }
} }
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Release;
use Closure; use Closure;
use LIT\MCP\Compatibility\CompatibilityMatrix; use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\ManifestCheck; use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Compatibility\Problem; use LIT\MCP\Compatibility\Problem;
use LIT\MCP\Plugin; use LIT\MCP\Plugin;
@@ -53,7 +54,8 @@ final class ReleaseResolver
if (in_array($version, DiviReleases::TESTED, true)) { if (in_array($version, DiviReleases::TESTED, true)) {
return ReleaseStatus::of($version, self::matrix($problems, CompatibilityMatrix::SOURCE_CODE, false)); return ReleaseStatus::of($version, self::matrix($problems, CompatibilityMatrix::SOURCE_CODE, false));
} }
$entry = $version === '' ? null : ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION); $store = $this->store ?? new ReleaseStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
$entry = $version === '' ? null : $store->find($version, Plugin::VERSION);
if ($entry !== null) { if ($entry !== null) {
return ReleaseStatus::of( return ReleaseStatus::of(
$version, $version,
@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* Divi versions released on this site after a green admin check (spec §5.3, source "database"). An entry holds for
* the plugin version that ran the check only.
*
* Not final so the resolver can be tested with a mock.
*/
class ReleaseStore
{
public const OPTION = 'lit_mcp_divi_releases';
/**
* @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
* report_id: string}>
*/
public function entries(): array
{
$raw = get_option(self::OPTION, []);
$entries = [];
foreach (is_array($raw) ? $raw : [] as $row) {
if (
is_array($row)
&& is_string($row['divi_version'] ?? null)
&& is_string($row['plugin_version'] ?? null)
&& is_int($row['user_id'] ?? null)
&& is_string($row['released_at'] ?? null)
&& is_string($row['report_id'] ?? null)
) {
$entries[] = [
'divi_version' => $row['divi_version'],
'plugin_version' => $row['plugin_version'],
'user_id' => $row['user_id'],
'released_at' => $row['released_at'],
'report_id' => $row['report_id'],
];
}
}
return $entries;
}
/**
* @return array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
* report_id: string}|null
*/
public function find(string $diviVersion, string $pluginVersion): ?array
{
foreach ($this->entries() as $entry) {
if ($entry['divi_version'] === $diviVersion && $entry['plugin_version'] === $pluginVersion) {
return $entry;
}
}
return null;
}
public function add(string $diviVersion, string $pluginVersion, int $userId, string $reportId): void
{
$entries = $this->without($diviVersion, $pluginVersion);
$entries[] = [
'divi_version' => $diviVersion,
'plugin_version' => $pluginVersion,
'user_id' => $userId,
'released_at' => gmdate('c'),
'report_id' => $reportId,
];
update_option(self::OPTION, $entries, false);
}
public function withdraw(string $diviVersion, string $pluginVersion): bool
{
if ($this->find($diviVersion, $pluginVersion) === null) {
return false;
}
update_option(self::OPTION, $this->without($diviVersion, $pluginVersion), false);
return true;
}
/**
* @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
* report_id: string}>
*/
private function without(string $diviVersion, string $pluginVersion): array
{
return array_values(array_filter(
$this->entries(),
static fn (array $entry): bool => $entry['divi_version'] !== $diviVersion
|| $entry['plugin_version'] !== $pluginVersion
));
}
}
+4 -1
View File
@@ -8,6 +8,7 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Pages\DiviPages; use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer; use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Log;
use Throwable; use Throwable;
use WP_Error; use WP_Error;
use WP_Post; use WP_Post;
@@ -101,7 +102,9 @@ class WriteProbe
try { try {
$problems = $this->exercise($id); $problems = $this->exercise($id);
} catch (Throwable $e) { } catch (Throwable $e) {
$problems = ['Schreibtest abgebrochen: ' . $e->getMessage()]; // The report reaches MCP clients through the lock reason; the exception text stays in the log.
Log::failure('divi write test', $e);
$problems = ['Schreibtest abgebrochen (Details im PHP-Error-Log).'];
} finally { } finally {
if (! self::delete($id)) { if (! self::delete($id)) {
$problems[] = sprintf( $problems[] = sprintf(
+4 -1
View File
@@ -130,7 +130,10 @@ final class IntegrationRegistry
*/ */
public function compatibility(): Compatibility public function compatibility(): Compatibility
{ {
return $this->compatibility ??= new Compatibility($this->compatibilities()); return $this->compatibility ??= new Compatibility(
$this->compatibilities(),
report: Compatibility::storedReport(...)
);
} }
/** @return list<string> Ids of all known integrations, available or not. */ /** @return list<string> Ids of all known integrations, available or not. */
+9 -8
View File
@@ -24,11 +24,11 @@ final class PodsCompatibility
$all = PodsFeature::cases(); $all = PodsFeature::cases();
return new Profile( return new Profile(
'pods', id: 'pods',
'Pods', label: 'Pods',
Tier::LockOnFailure, tier: Tier::LockOnFailure,
$all, capabilities: $all,
[new Manifest( manifests: [new Manifest(
'Pods', 'Pods',
[ [
new Call('pods_api', 0, [PodsFeature::SchemaRead]), new Call('pods_api', 0, [PodsFeature::SchemaRead]),
@@ -41,9 +41,10 @@ final class PodsCompatibility
], ],
[new ConstantRequirement('PODS_VERSION', $all)] [new ConstantRequirement('PODS_VERSION', $all)]
)], )],
static fn (): bool => defined('PODS_VERSION'), active: static fn (): bool => defined('PODS_VERSION'),
static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null, version: static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
'2.8' minVersion: '2.8',
testedVersions: ['3.3.9.2']
); );
} }
} }
+1 -1
View File
@@ -231,7 +231,7 @@ final class Plugin
// The sources exist before the registry, so Pods gets its own Compatibility here – a deliberate, cheap // The sources exist before the registry, so Pods gets its own Compatibility here – a deliberate, cheap
// duplicate of the registry's (a few function_exists checks, memoised per request). // duplicate of the registry's (a few function_exists checks, memoised per request).
$sources = self::schemaSources( $sources = self::schemaSources(
new Compatibility([PodsCompatibility::profile()]), new Compatibility([PodsCompatibility::profile()], report: Compatibility::storedReport(...)),
defined('PODS_VERSION') defined('PODS_VERSION')
); );
+6 -3
View File
@@ -17,9 +17,11 @@ defined('ABSPATH') || exit;
* so the Composer/Jetpack autoloader is not guaranteed to have run. The option/hook/table * so the Composer/Jetpack autoloader is not guaranteed to have run. The option/hook/table
* names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION, * names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION,
* Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\ * Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\
* ReleaseStore::OPTION, Integrations\Divi\Release\CheckReportStore::OPTION, Integrations\ * lit_mcp_<profile>_releases (Compatibility\ReleaseStore), lit_mcp_<profile>_check_report
* Divi\Release\DiviCheckRoute::LOCK, Integrations\Divi\Design\DesignLock::option(), * (Compatibility\CheckReportStore) and lit_mcp_<profile>_check_lock (Divi\Release\DiviCheckRoute::LOCK) for the
* Admin\ConnectionStatus::ENDPOINT_TRANSIENT, Integrations\Divi\Release\ReleaseResolver::CACHE, * profiles divi and pods (an explicit list: a LIKE pattern could hit foreign options),
* Integrations\Divi\Design\DesignLock::option(), Admin\ConnectionStatus::ENDPOINT_TRANSIENT,
* Integrations\Divi\Release\ReleaseResolver::CACHE,
* ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE, Admin\LogTab::PER_PAGE_OPTION and * ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE, Admin\LogTab::PER_PAGE_OPTION and
* Updater\RecheckOnInstall::OPTION on purpose; * Updater\RecheckOnInstall::OPTION on purpose;
* tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in sync with * tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in sync with
@@ -35,6 +37,7 @@ final class Uninstaller
private const OPTIONS = [ private const OPTIONS = [
'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version', 'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version',
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock', 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock',
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css', 'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
'lit_mcp_lock_divi_options', 'lit_mcp_lock_variables', 'lit_mcp_lock_presets', 'lit_mcp_lock_custom_css', 'lit_mcp_lock_divi_options', 'lit_mcp_lock_variables', 'lit_mcp_lock_presets', 'lit_mcp_lock_custom_css',
@@ -59,7 +59,7 @@ final class CompatibilityGatesTest extends TestCase
$ability->allows('requirements')->andReturn($ids); $ability->allows('requirements')->andReturn($ids);
$ability->allows('permission')->andReturn(true); $ability->allows('permission')->andReturn(true);
$free = new Profile('fake', 'Fake Plugin', Tier::LockOnFailure, [FakeCapability::Read]); $free = new Profile(id: 'fake', label: 'Fake Plugin', tier: Tier::LockOnFailure, capabilities: [FakeCapability::Read]);
$compat = new Compatibility([DiviProfile::profile(), PodsCompatibility::profile(), $free]); $compat = new Compatibility([DiviProfile::profile(), PodsCompatibility::profile(), $free]);
$integration = Mockery::mock(Integration::class); $integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn('core'); $integration->allows('id')->andReturn('core');
@@ -40,17 +40,17 @@ final class CapabilityGateTest extends TestCase
private function gate(?FakeChangeLog $log = null, array $delegated = []): CapabilityGate private function gate(?FakeChangeLog $log = null, array $delegated = []): CapabilityGate
{ {
$lock = new Profile( $lock = new Profile(
'fake', id: 'fake',
'Fake Plugin', label: 'Fake Plugin',
Tier::LockOnFailure, tier: Tier::LockOnFailure,
[FakeCapability::Read, FakeCapability::Write], capabilities: [FakeCapability::Read, FakeCapability::Write],
[new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])])] manifests: [new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])])]
); );
$release = new Profile( $release = new Profile(
'release', id: 'release',
'Release', label: 'Release',
Tier::ReleasePerVersion, tier: Tier::ReleasePerVersion,
[FakeCapability::Other], capabilities: [FakeCapability::Other],
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible( matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(
[FakeCapability::Other], [FakeCapability::Other],
'would lock' 'would lock'
@@ -18,7 +18,7 @@ final class CapabilityLockedTest extends TestCase
{ {
public function testTheErrorNamesPluginCapabilitiesAndProblems(): void public function testTheErrorNamesPluginCapabilitiesAndProblems(): void
{ {
$profile = new Profile('fake', 'Fake Plugin', Tier::LockOnFailure, [FakeCapability::Write]); $profile = new Profile(id: 'fake', label: 'Fake Plugin', tier: Tier::LockOnFailure, capabilities: [FakeCapability::Write]);
$locked = CapabilityLocked::of( $locked = CapabilityLocked::of(
[new FeatureStatus(FakeCapability::Write, ReleaseState::Incompatible, ['Fake: x() fehlt'])], [new FeatureStatus(FakeCapability::Write, ReleaseState::Incompatible, ['Fake: x() fehlt'])],
[$profile] [$profile]
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use Brain\Monkey\Functions;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Unit\TestCase;
final class CheckReportTest extends TestCase
{
/** @var array<string, mixed> */
private array $options = [];
protected function setUp(): void
{
parent::setUp();
$this->options = [];
Functions\when('get_option')->alias(
fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default
);
Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool {
$this->options[$name] = $value;
self::assertFalse($autoload, 'the report is read in the admin only – no autoload');
return true;
});
}
/** @param list<CheckStep> $steps */
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);
}
/** @return list<CheckStep> */
private static function green(): array
{
return array_map(
static fn (string $key): CheckStep => new CheckStep($key, []),
[CheckStep::MANIFEST, CheckStep::MIRRORS, 'catalog']
);
}
/** What the Divi report looked like before the generic version: no label per step, key divi_version. */
private static function oldDiviReport(): array
{
return [
'id' => 'r1',
'created_at' => '2026-10-01T08:00:00+00:00',
'user_id' => 1,
'divi_version' => '5.3.0',
'plugin_version' => '0.6.0',
'state_key' => 'key',
'steps' => [
['key' => 'manifest', 'problems' => [], 'skipped' => false],
['key' => 'mirrors', 'problems' => ['Theme Builder: Nachbau von x() – y'], 'skipped' => false],
['key' => 'write', 'problems' => [], 'skipped' => true],
],
];
}
public function testGreenOnlyWithEveryStepGreenAndNoneSkipped(): void
{
self::assertTrue(self::report(self::green())->green());
self::assertFalse(self::report([])->green());
$red = self::green();
$red[2] = new CheckStep('catalog', ['divi/text: unbekanntes Modul']);
self::assertFalse(self::report($red)->green());
$skipped = self::green();
$skipped[2] = new CheckStep('catalog', [], 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'), 'plugin under test updated');
self::assertFalse($report->freshFor('5.3.0', '0.7.0', 'key'), 'this plugin updated');
self::assertFalse($report->freshFor('5.3.0', '0.6.0', 'other'), 'a source file changed');
}
public function testRoundTripsThroughAnArrayWithTheLabel(): void
{
$steps = self::green();
$steps[1] = new CheckStep(CheckStep::MIRRORS, ['x'], false, 'Fingerabdrücke');
$report = self::report($steps);
self::assertEquals($report, CheckReport::fromArray($report->toArray()));
self::assertSame('Fingerabdrücke', $report->step(CheckStep::MIRRORS)?->label());
self::assertSame('Fingerabdrücke', $report->toArray()['steps'][1]['label']);
self::assertNull(CheckReport::fromArray('broken'));
self::assertNull(CheckReport::fromArray(['id' => 'r1']));
}
public function testAStepWithoutLabelShowsItsKeyAndIsWrittenWithoutOne(): void
{
$step = new CheckStep('catalog', []);
self::assertSame('catalog', $step->label());
self::assertArrayNotHasKey('label', $step->toArray());
}
public function testTheGenericStepsHaveOneLabelSourceWithTheProfileName(): void
{
self::assertSame('Pods-Funktionen, Klassen und Registrierungen', CheckStep::genericLabel(CheckStep::MANIFEST, 'Pods'));
self::assertSame('Fingerabdrücke nachgebauter Pods-Funktionen', CheckStep::genericLabel(CheckStep::MIRRORS, 'Pods'));
self::assertNull(CheckStep::genericLabel('catalog', 'Pods'));
}
public function testAReportWithoutStepsIsNotGreen(): void
{
self::assertFalse(self::report([])->green());
}
public function testAnOldDiviReportIsReadAndWrittenUnchanged(): void
{
$old = self::oldDiviReport();
$report = CheckReport::fromArray($old, 'divi_version');
self::assertSame('5.3.0', $report?->version);
self::assertSame('manifest', $report?->steps[0]->label(), 'no stored label: the raw key');
self::assertSame($old, $report?->toArray('divi_version'));
self::assertNull(CheckReport::fromArray($old), 'the default key is "version"');
}
public function testTheStoreKeepsTheDiviOptionAndFormat(): void
{
$this->options['lit_mcp_divi_check_report'] = self::oldDiviReport();
$store = new CheckReportStore('divi', 'divi_version');
$report = $store->latest();
self::assertSame('r1', $report?->id);
$store->save($report);
self::assertSame(self::oldDiviReport(), $this->options['lit_mcp_divi_check_report']);
}
public function testTheStoresOfAProfileUseItsIdAndVersionKey(): void
{
$profile = new Profile(
id: 'divi',
label: 'Divi',
tier: Tier::ReportOnly,
capabilities: [FakeCapability::Read],
reportVersionKey: 'divi_version'
);
$this->options['lit_mcp_divi_check_report'] = self::oldDiviReport();
$this->options['lit_mcp_divi_releases'] = [[
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3,
'released_at' => '2026-10-01T08:00:00+00:00', 'report_id' => 'r',
]];
self::assertSame('r1', CheckReportStore::forProfile($profile)->latest()?->id);
self::assertSame('r', ReleaseStore::forProfile($profile)->find('5.2.2', '0.6.0')['report_id'] ?? null);
}
public function testStoredReportReadsTheDiviFormatForAProfileWithItsKey(): void
{
$profile = new Profile(
id: 'divi',
label: 'Divi',
tier: Tier::ReportOnly,
capabilities: [FakeCapability::Read],
reportVersionKey: 'divi_version'
);
$this->options['lit_mcp_divi_check_report'] = self::oldDiviReport();
self::assertSame('5.3.0', Compatibility::storedReport($profile)?->version);
}
public function testTheStoreUsesAnOptionAndVersionKeyPerProfile(): void
{
$store = new CheckReportStore('pods');
self::assertNull($store->latest());
$store->save(self::report(self::green()));
self::assertSame(['lit_mcp_pods_check_report'], array_keys($this->options));
self::assertSame('5.3.0', $this->options['lit_mcp_pods_check_report']['version']);
self::assertSame('5.3.0', $store->latest()?->version);
self::assertNull((new CheckReportStore('divi', 'divi_version'))->latest());
}
}
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class CompatibilityCheckTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_generate_uuid4')->justReturn('uuid-1');
}
/** @param list<Manifest> $manifests */
private static function profile(array $manifests = [], ?Closure $extraSteps = null, ?Closure $stateKey = null): Profile
{
return new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::LockOnFailure,
capabilities: [FakeCapability::Read],
manifests: $manifests,
version: static fn (): ?string => '2.1.0',
stateKey: $stateKey,
extraSteps: $extraSteps
);
}
private static function withMirrors(): Manifest
{
return new Manifest('Fake', mirrors: [new Mirror('m', 'f.php', 'fn', null, 'x')]);
}
/** @param array<string, Closure(): bool> $code */
private static function check(Profile $profile, array $code = [], array $mirrors = []): ManifestCheck
{
$check = Mockery::mock(ManifestCheck::class);
$check->allows('codeChecks')->andReturn($code);
$check->allows('registrationChecks')->andReturn([]);
$check->allows('mirrorChecks')->andReturn($mirrors);
return $check;
}
/** @return list<string> */
private static function keys(CompatibilityCheck $check): array
{
return array_map(static fn (CheckStep $step): string => $step->key, $check->run(7)->steps);
}
public function testAProfileWithoutMirrorsGetsOnlyTheManifestStep(): void
{
$profile = self::profile([new Manifest('Fake')]);
$report = (new CompatibilityCheck($profile, self::check($profile)))->run(7);
self::assertTrue($report->green());
self::assertSame(['manifest'], array_map(static fn (CheckStep $s): string => $s->key, $report->steps));
self::assertSame('Fake-Funktionen, Klassen und Registrierungen', $report->steps[0]->label());
self::assertSame(['uuid-1', 7, '2.1.0', Plugin::VERSION, ''], [
$report->id, $report->userId, $report->version, $report->pluginVersion, $report->stateKey,
]);
}
public function testAManifestWithMirrorsAddsTheFingerprintStep(): void
{
$profile = self::profile([self::withMirrors()], stateKey: static fn (string $v): string => 'key-' . $v);
$check = new CompatibilityCheck($profile, self::check($profile, [], ['TB: Nachbau von y()' => static fn (): bool => false]));
$report = $check->run(7);
self::assertSame(['manifest', 'mirrors'], array_map(static fn (CheckStep $s): string => $s->key, $report->steps));
self::assertSame(['TB: Nachbau von y()'], $report->step(CheckStep::MIRRORS)?->problems);
self::assertSame('Fingerabdrücke nachgebauter Fake-Funktionen', $report->step(CheckStep::MIRRORS)?->label());
self::assertSame('key-2.1.0', $report->stateKey);
self::assertFalse($report->green());
}
public function testExtraStepsFollowAndKnowWhetherTheManifestIsClean(): void
{
$seen = [];
$extra = static function (bool $clean) use (&$seen): array {
$seen[] = $clean;
return [new CheckStep('extra', [])];
};
$profile = self::profile([self::withMirrors()], $extra);
$clean = new CompatibilityCheck($profile, self::check($profile));
$broken = new CompatibilityCheck($profile, self::check($profile, ['Fake: f()' => static fn (): bool => false]));
self::assertSame(['manifest', 'mirrors', 'extra'], self::keys($clean));
self::assertSame(['manifest', 'mirrors', 'extra'], self::keys($broken));
self::assertSame([true, false], $seen);
}
public function testAThrowingManifestCheckIsARedStep(): void
{
$profile = self::profile([new Manifest('Fake')]);
$manifests = Mockery::mock(ManifestCheck::class);
$manifests->allows('codeChecks')->andThrow(new RuntimeException('kaputt'));
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$report = (new CompatibilityCheck($profile, $manifests))->run(7);
$logged = (string) file_get_contents($log);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
self::assertSame(
['Prüfschritt abgebrochen (Details im PHP-Error-Log).'],
$report->step(CheckStep::MANIFEST)?->problems
);
self::assertStringContainsString('kaputt', $logged, 'the exception text goes to the error log');
}
public function testForProfileUsesTheManifestCheckOfTheProfile(): void
{
$profile = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::ReportOnly,
capabilities: [FakeCapability::Read],
manifestCheck: static fn (): ManifestCheck => new ManifestCheck(
[new Manifest('Fake', [new \LIT\MCP\Compatibility\Call('lit_mcp_missing_fn', 0)])],
signatures: false
)
);
$report = CompatibilityCheck::forProfile($profile)->run(1);
self::assertSame(['Fake: lit_mcp_missing_fn()'], $report->step(CheckStep::MANIFEST)?->problems);
}
public function testCompatibilityChecksAProfileWithoutManifestCheckClosureByItsManifests(): void
{
$profile = self::profile([new Manifest('Fake', [new \LIT\MCP\Compatibility\Call('lit_mcp_missing_fn', 0)])]);
$report = (new Compatibility([$profile]))->check($profile, 3);
self::assertSame(3, $report->userId);
self::assertSame(['Fake: lit_mcp_missing_fn()'], $report->step(CheckStep::MANIFEST)?->problems);
}
}
@@ -22,16 +22,16 @@ final class CompatibilityComponentTest extends TestCase
private function profile(bool $active, bool $broken = false): Profile private function profile(bool $active, bool $broken = false): Profile
{ {
return new Profile( return new Profile(
'fake', id: 'fake',
'Fake Plugin', label: 'Fake Plugin',
Tier::LockOnFailure, tier: Tier::LockOnFailure,
[FakeCapability::Read, FakeCapability::Write], capabilities: [FakeCapability::Read, FakeCapability::Write],
[new Manifest( manifests: [new Manifest(
'Fake', 'Fake',
calls: $broken ? [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])] : [] calls: $broken ? [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])] : []
)], )],
static fn (): bool => $active, active: static fn (): bool => $active,
static fn (): string => '4.2.0' version: static fn (): string => '4.2.0'
); );
} }
@@ -85,10 +85,10 @@ final class CompatibilityComponentTest extends TestCase
public function testAReleasePerVersionProfileHasItsOwnComponent(): void public function testAReleasePerVersionProfileHasItsOwnComponent(): void
{ {
$profile = new Profile( $profile = new Profile(
'rel', id: 'rel',
'Rel', label: 'Rel',
Tier::ReleasePerVersion, tier: Tier::ReleasePerVersion,
[FakeCapability::Other], capabilities: [FakeCapability::Other],
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other]) matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other])
); );
@@ -101,10 +101,10 @@ final class CompatibilityComponentTest extends TestCase
{ {
$plain = $this->profile(true); $plain = $this->profile(true);
$release = new Profile( $release = new Profile(
'rel', id: 'rel',
'Rel', label: 'Rel',
Tier::ReleasePerVersion, tier: Tier::ReleasePerVersion,
[FakeCapability::Other], capabilities: [FakeCapability::Other],
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other]) matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other])
); );
+158 -24
View File
@@ -6,6 +6,8 @@ namespace LIT\MCP\Tests\Unit\Compatibility;
use Closure; use Closure;
use LIT\MCP\Compatibility\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\Compatibility; use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\CompatibilityMatrix; use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\Manifest; use LIT\MCP\Compatibility\Manifest;
@@ -13,6 +15,7 @@ use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Profile; use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ReleaseState; use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\Tier; use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeCapability; use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use LogicException; use LogicException;
@@ -36,26 +39,26 @@ final class CompatibilityTest extends TestCase
Tier $tier = Tier::LockOnFailure Tier $tier = Tier::LockOnFailure
): Profile { ): Profile {
return new Profile( return new Profile(
'fake', id: 'fake',
'Fake Plugin', label: 'Fake Plugin',
$tier, tier: $tier,
self::CAPS, capabilities: self::CAPS,
$broken ? [self::brokenManifest()] : [], manifests: $broken ? [self::brokenManifest()] : [],
$active, active: $active,
$version, version: $version,
$minVersion minVersion: $minVersion
); );
} }
public function testReportOnlyNeverLocksNotEvenWhenInactiveOrBroken(): void public function testReportOnlyNeverLocksNotEvenWhenInactiveOrBroken(): void
{ {
$profile = new Profile( $profile = new Profile(
'fake', id: 'fake',
'Fake', label: 'Fake',
Tier::ReportOnly, tier: Tier::ReportOnly,
self::CAPS, capabilities: self::CAPS,
[self::brokenManifest()], manifests: [self::brokenManifest()],
static fn (): bool => false, active: static fn (): bool => false,
minVersion: '9' minVersion: '9'
); );
$compat = new Compatibility([$profile]); $compat = new Compatibility([$profile]);
@@ -155,10 +158,10 @@ final class CompatibilityTest extends TestCase
public function testReleasePerVersionTakesItsMatrixFromTheClosure(): void public function testReleasePerVersionTakesItsMatrixFromTheClosure(): void
{ {
$profile = new Profile( $profile = new Profile(
'fake', id: 'fake',
'Fake', label: 'Fake',
Tier::ReleasePerVersion, tier: Tier::ReleasePerVersion,
self::CAPS, capabilities: self::CAPS,
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(self::CAPS, 'gesperrt') matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(self::CAPS, 'gesperrt')
); );
$compat = new Compatibility([$profile]); $compat = new Compatibility([$profile]);
@@ -173,10 +176,10 @@ final class CompatibilityTest extends TestCase
$matrices = 0; $matrices = 0;
$lock = self::lockProfile(); $lock = self::lockProfile();
$release = new Profile( $release = new Profile(
'release', id: 'release',
'Release', label: 'Release',
Tier::ReleasePerVersion, tier: Tier::ReleasePerVersion,
[FakeCapability::Other], capabilities: [FakeCapability::Other],
matrix: static function () use (&$matrices): CompatibilityMatrix { matrix: static function () use (&$matrices): CompatibilityMatrix {
$matrices++; $matrices++;
@@ -203,7 +206,7 @@ final class CompatibilityTest extends TestCase
public function testLookups(): void public function testLookups(): void
{ {
$lock = self::lockProfile(); $lock = self::lockProfile();
$other = new Profile('other', 'Other', Tier::ReportOnly, [FakeCapability::Other]); $other = new Profile(id: 'other', label: 'Other', tier: Tier::ReportOnly, capabilities: [FakeCapability::Other]);
$compat = new Compatibility([$lock, $other]); $compat = new Compatibility([$lock, $other]);
self::assertSame([$lock, $other], $compat->profiles()); self::assertSame([$lock, $other], $compat->profiles());
@@ -222,7 +225,7 @@ final class CompatibilityTest extends TestCase
new Compatibility([ new Compatibility([
self::lockProfile(), self::lockProfile(),
new Profile('other', 'Other', Tier::ReportOnly, [FakeCapability::Read]), new Profile(id: 'other', label: 'Other', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read]),
]); ]);
} }
@@ -230,4 +233,135 @@ final class CompatibilityTest extends TestCase
{ {
self::assertFalse((new Compatibility([]))->usable(FakeCapability::Read)); self::assertFalse((new Compatibility([]))->usable(FakeCapability::Read));
} }
private static function report(bool $green, string $version = '2.1.0', string $stateKey = 'k1'): CheckReport
{
return new CheckReport('r1', 'd', 1, $version, Plugin::VERSION, $stateKey, [
new CheckStep('probe', $green ? [] : ['Schreibprobe fehlgeschlagen']),
]);
}
private static function reported(?CheckReport $report, bool $withStateKey = true): Compatibility
{
$profile = new Profile(
id: 'fake',
label: 'Fake Plugin',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
version: static fn (): ?string => '2.1.0',
stateKey: $withStateKey ? static fn (string $version): string => 'k1' : null
);
return new Compatibility([$profile], report: static fn (Profile $p): ?CheckReport => $report);
}
public function testARedReportOfTheCurrentStateLocksEveryCapability(): void
{
$compat = self::reported(self::report(false));
self::assertSame(ReleaseState::Incompatible, $compat->matrix($compat->profile('fake'))->state());
self::assertNotNull($compat->lockOf(self::CAPS));
$reason = implode(' ', $compat->problems($compat->profile('fake')));
self::assertStringContainsString('(probe)', $reason);
self::assertStringNotContainsString('Schreibprobe fehlgeschlagen', $reason, 'step texts may hold exception text');
}
public function testAReportWithoutStepsIsNoResultAndDoesNotLock(): void
{
$empty = new CheckReport('r1', 'd', 1, '2.1.0', Plugin::VERSION, 'k1', []);
self::assertNull(self::reported($empty)->lockOf(self::CAPS));
}
public function testActiveAndVersionRunTheirClosuresOncePerInstance(): void
{
$calls = 0;
$profile = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
active: static function () use (&$calls): bool {
$calls++;
return true;
},
version: static function () use (&$calls): string {
$calls++;
return '1.0';
}
);
$compat = new Compatibility([$profile]);
foreach ([1, 2, 3] as $ignored) {
self::assertTrue($compat->active($profile));
self::assertSame('1.0', $compat->version($profile));
}
self::assertSame(2, $calls);
}
public function testAThrowingActiveClosureIsLoggedOnlyOnce(): void
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
$profile = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
active: static fn (): bool => throw new RuntimeException('einmal')
);
$compat = new Compatibility([$profile]);
try {
$compat->active($profile);
$compat->active($profile);
$compat->lockOf(self::CAPS);
$logged = (string) file_get_contents($log);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
self::assertSame(1, substr_count($logged, 'einmal'));
}
public function testAnOtherStateOrAGreenReportOrNoneDoesNotLock(): void
{
foreach ([self::report(false, stateKey: 'old'), self::report(false, version: '2.0.0'), self::report(true), null] as $report) {
self::assertNull(self::reported($report)->lockOf(self::CAPS));
}
}
public function testWithoutAStateKeyTheVersionDecides(): void
{
self::assertNotNull(self::reported(self::report(false, stateKey: ''), false)->lockOf(self::CAPS));
self::assertNull(self::reported(self::report(false, version: '9', stateKey: ''), false)->lockOf(self::CAPS));
}
public function testACaughtThrowableIsLogged(): void
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
$profile = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
active: static fn (): bool => throw new RuntimeException('geheim-xyz')
);
try {
self::assertFalse((new Compatibility([$profile]))->active($profile));
$logged = (string) file_get_contents($log);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
self::assertStringContainsString('compat fake failed', $logged);
self::assertStringContainsString('geheim-xyz', $logged);
}
} }
+49 -6
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility; namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\CompatibilityMatrix; use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Profile; use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier; use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Tests\Support\FakeCapability; use LIT\MCP\Tests\Support\FakeCapability;
@@ -17,10 +18,10 @@ final class ProfileTest extends TestCase
{ {
$matrix = static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Read]); $matrix = static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Read]);
$profile = new Profile( $profile = new Profile(
'fake', id: 'fake',
'Fake Plugin', label: 'Fake Plugin',
Tier::ReleasePerVersion, tier: Tier::ReleasePerVersion,
[FakeCapability::Read], capabilities: [FakeCapability::Read],
matrix: $matrix, matrix: $matrix,
minVersion: '2.0' minVersion: '2.0'
); );
@@ -34,12 +35,54 @@ final class ProfileTest extends TestCase
self::assertSame([], $profile->manifests); self::assertSame([], $profile->manifests);
} }
public function testTheStoredVersionKeyDefaultsToVersionAndIsKeptWhenGiven(): void
{
$plain = new Profile(id: 'fake', label: 'Fake', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read]);
$divi = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::ReportOnly,
capabilities: [FakeCapability::Read],
reportVersionKey: 'divi_version'
);
self::assertSame('version', $plain->reportVersionKey);
self::assertSame('divi_version', $divi->reportVersionKey);
}
public function testTheReleaseFieldsAreOptionalAndKeptWhenGiven(): void
{
$bare = new Profile(id: 'fake', label: 'Fake', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read]);
self::assertSame([], $bare->testedVersions);
self::assertNull($bare->stateKey);
self::assertNull($bare->extraSteps);
self::assertNull($bare->manifestCheck);
$stateKey = static fn (string $version): string => 'k' . $version;
$extraSteps = static fn (bool $manifestClean): array => [];
$manifestCheck = static fn (): ManifestCheck => new ManifestCheck([], signatures: false);
$full = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::ReportOnly,
capabilities: [FakeCapability::Read],
testedVersions: ['1.0'],
stateKey: $stateKey,
extraSteps: $extraSteps,
manifestCheck: $manifestCheck
);
self::assertSame(['1.0'], $full->testedVersions);
self::assertSame($stateKey, $full->stateKey);
self::assertSame($extraSteps, $full->extraSteps);
self::assertSame($manifestCheck, $full->manifestCheck);
}
public function testAReleasePerVersionProfileWithoutMatrixIsAProgrammingError(): void public function testAReleasePerVersionProfileWithoutMatrixIsAProgrammingError(): void
{ {
$this->expectException(LogicException::class); $this->expectException(LogicException::class);
$this->expectExceptionMessage('needs a matrix closure'); $this->expectExceptionMessage('needs a matrix closure');
new Profile('fake', 'Fake', Tier::ReleasePerVersion, [FakeCapability::Read]); new Profile(id: 'fake', label: 'Fake', tier: Tier::ReleasePerVersion, capabilities: [FakeCapability::Read]);
} }
public function testCapabilityIdsMustBeUniqueWithinAProfile(): void public function testCapabilityIdsMustBeUniqueWithinAProfile(): void
@@ -47,7 +90,7 @@ final class ProfileTest extends TestCase
$this->expectException(LogicException::class); $this->expectException(LogicException::class);
$this->expectExceptionMessage('fake-read'); $this->expectExceptionMessage('fake-read');
new Profile('fake', 'Fake', Tier::ReportOnly, [FakeCapability::Read, FakeCapability::Read]); new Profile(id: 'fake', label: 'Fake', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read, FakeCapability::Read]);
} }
public function testTheTiersHaveStableValues(): void public function testTheTiersHaveStableValues(): void
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use Brain\Monkey\Functions;
use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Tests\Unit\TestCase;
final class ReleaseStoreTest extends TestCase
{
/** @var array<string, mixed> */
private array $options = [];
protected function setUp(): void
{
parent::setUp();
$this->options = [];
Functions\when('get_option')->alias(
fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default
);
Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool {
$this->options[$name] = $value;
self::assertFalse($autoload, 'releases are read only by the gate – no autoload');
return true;
});
}
public function testAddsFindsAndWithdrawsPerVersionAndPluginVersion(): void
{
$store = new ReleaseStore('divi', 'divi_version');
$store->add('5.3.0', '0.6.0', 1, 'r1');
$store->add('5.3.0', '0.6.0', 2, 'r2');
self::assertCount(1, $store->entries(), 'one entry per version pair');
self::assertSame(2, $store->find('5.3.0', '0.6.0')['user_id'] ?? null);
self::assertNull($store->find('5.3.0', '0.7.0'), 'another plugin version needs its own check');
self::assertTrue($store->withdraw('5.3.0', '0.6.0'));
self::assertFalse($store->withdraw('5.3.0', '0.6.0'));
self::assertSame([], $store->entries());
}
public function testDiviKeepsOptionAndFormat(): void
{
$this->options['lit_mcp_divi_releases'] = [[
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3,
'released_at' => '2026-10-01T08:00:00+00:00', 'report_id' => 'r',
]];
$store = new ReleaseStore('divi', 'divi_version');
self::assertSame('r', $store->find('5.2.2', '0.6.0')['report_id'] ?? null);
$store->add('5.3.0', '0.6.0', 1, 'r1');
$written = $this->options['lit_mcp_divi_releases'];
self::assertSame(['lit_mcp_divi_releases'], array_keys($this->options));
self::assertSame(
['divi_version', 'plugin_version', 'user_id', 'released_at', 'report_id'],
array_keys($written[1])
);
self::assertSame($this->options['lit_mcp_divi_releases'][0], [
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3,
'released_at' => '2026-10-01T08:00:00+00:00', 'report_id' => 'r',
]);
}
public function testAnotherProfileGetsItsOwnOptionAndTheVersionKey(): void
{
$store = new ReleaseStore('pods');
$store->add('3.3.9.2', '0.6.0', 1, 'r1');
self::assertSame(['lit_mcp_pods_releases'], array_keys($this->options));
self::assertSame('3.3.9.2', $this->options['lit_mcp_pods_releases'][0]['version']);
self::assertSame('r1', $store->find('3.3.9.2', '0.6.0')['report_id'] ?? null);
self::assertSame([], (new ReleaseStore('divi', 'divi_version'))->entries());
}
public function testIgnoresBrokenRows(): void
{
$this->options['lit_mcp_divi_releases'] = ['x', ['divi_version' => '5.3.0'], [
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3,
'released_at' => 'd', 'report_id' => 'r',
]];
self::assertSame(['5.2.2'], array_column((new ReleaseStore('divi', 'divi_version'))->entries(), 'divi_version'));
self::assertSame([], (new ReleaseStore('divi'))->entries(), 'rows hold divi_version, not version');
}
}
@@ -9,15 +9,16 @@ use Brain\Monkey\Functions;
use LIT\MCP\Admin\AdminPostAction; use LIT\MCP\Admin\AdminPostAction;
use LIT\MCP\Compatibility\ReleaseState; use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Integrations\Divi\Admin\DiviTab; use LIT\MCP\Integrations\Divi\Admin\DiviTab;
use LIT\MCP\Integrations\Divi\Release\CheckReport; use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore; use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep; use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview; use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Compatibility\FeatureStatus; use LIT\MCP\Compatibility\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore; use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Integrations\Divi\Release\WriteProbe; use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin; use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeChangeLog; use LIT\MCP\Tests\Support\FakeChangeLog;
@@ -63,7 +64,7 @@ final class DiviTabTest extends TestCase
{ {
$steps = array_map( $steps = array_map(
static fn (string $step): CheckStep => new CheckStep($step, $green ? [] : ['rot']), static fn (string $step): CheckStep => new CheckStep($step, $green ? [] : ['rot']),
[CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] [CheckStep::MANIFEST, CheckStep::MIRRORS, DiviCheckSteps::CATALOG, DiviCheckSteps::FORMAT, DiviCheckSteps::WRITE]
); );
return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, $version, Plugin::VERSION, $key, $steps); return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, $version, Plugin::VERSION, $key, $steps);
@@ -7,10 +7,11 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Cli;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Compatibility\ReleaseState; use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Integrations\Divi\Cli\DiviCheckCommand; use LIT\MCP\Integrations\Divi\Cli\DiviCheckCommand;
use LIT\MCP\Integrations\Divi\Release\CheckReport; use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore; use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep; use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Compatibility\CompatibilityMatrix; use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviReference; use LIT\MCP\Integrations\Divi\Release\DiviReference;
@@ -74,7 +75,7 @@ final class DiviCheckCommandTest extends TestCase
): DiviCheckCommand { ): 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, DiviCheckSteps::CATALOG, DiviCheckSteps::FORMAT, DiviCheckSteps::WRITE]
); );
$check = Mockery::mock(CompatibilityCheck::class); $check = Mockery::mock(CompatibilityCheck::class);
$check->allows('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', $steps)); $check->allows('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', $steps));
@@ -121,14 +122,14 @@ final class DiviCheckCommandTest extends TestCase
public function testFailsWhenAStepIsRed(): void public function testFailsWhenAStepIsRed(): void
{ {
self::assertSame(1, $this->command([CheckStep::CATALOG => ['divi/text: unbekanntes Modul']])->run([])); self::assertSame(1, $this->command([DiviCheckSteps::CATALOG => ['divi/text: unbekanntes Modul']])->run([]));
self::assertContains('✗ Modulkatalog (Kurzform-Pfade)', $this->lines); self::assertContains('✗ Modulkatalog (Kurzform-Pfade)', $this->lines);
self::assertContains(' divi/text: unbekanntes Modul', $this->lines); self::assertContains(' divi/text: unbekanntes Modul', $this->lines);
} }
public function testUpdateReferenceRefusesWhenMoreThanFingerprintsIsRed(): void public function testUpdateReferenceRefusesWhenMoreThanFingerprintsIsRed(): void
{ {
$code = $this->command([CheckStep::FORMAT => ['kaputt']])->run(['update-reference' => true]); $code = $this->command([DiviCheckSteps::FORMAT => ['kaputt']])->run(['update-reference' => true]);
self::assertSame(1, $code); self::assertSame(1, $code);
self::assertFileDoesNotExist($this->expectedFile); self::assertFileDoesNotExist($this->expectedFile);
@@ -324,7 +325,7 @@ final class DiviCheckCommandTest extends TestCase
public function testARedReportKeepsExitOneWhateverTheMatrixSays(): void public function testARedReportKeepsExitOneWhateverTheMatrixSays(): void
{ {
$red = [CheckStep::CATALOG => ['divi/text: unbekanntes Modul']]; $red = [DiviCheckSteps::CATALOG => ['divi/text: unbekanntes Modul']];
$incompatible = MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Incompatible, 'auto', 'x'); $incompatible = MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Incompatible, 'auto', 'x');
self::assertSame(1, $this->command($red)->run([])); self::assertSame(1, $this->command($red)->run([]));
@@ -1,61 +0,0 @@
<?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());
}
}
@@ -1,92 +0,0 @@
<?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\Compatibility\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);
}
}
@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\CheckReport; use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore; use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use LIT\MCP\Tests\Unit\TestCase;
use Brain\Monkey\Functions;
use Closure;
use Mockery;
use RuntimeException;
final class DiviCheckStepsTest extends TestCase
{
public function testAStepCarriesItsLabel(): void
{
$step = DiviCheckSteps::step(DiviCheckSteps::CATALOG, ['x']);
self::assertSame('catalog', $step->key);
self::assertSame(['x'], $step->problems);
self::assertSame('Modulkatalog (Kurzform-Pfade)', $step->label());
self::assertTrue(DiviCheckSteps::step(DiviCheckSteps::WRITE, [], true)->skipped);
}
public function testTheLabelOfAnOldReportStepComesFromTheKey(): void
{
self::assertSame(
'Schreibtest auf einer Wegwerfseite',
DiviCheckSteps::label(new CheckStep(DiviCheckSteps::WRITE, []))
);
self::assertSame(
'Divi-Funktionen, Klassen und Registrierungen',
DiviCheckSteps::label(new CheckStep(CheckStep::MANIFEST, []))
);
self::assertSame('stored', DiviCheckSteps::label(new CheckStep('catalog', [], false, 'stored')));
self::assertSame('other', DiviCheckSteps::label(new CheckStep('other', [])));
}
/** @param array<string, Closure(): bool> $code */
private function check(array $code, array $mirrors, ?Closure $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([]);
$profile = new Profile(
id: 'divi',
label: 'Divi',
tier: Tier::ReleasePerVersion,
capabilities: DiviFeature::cases(),
manifests: [new Manifest('Seiten', mirrors: [new Mirror('m', 'f.php', 'fn', null, 'x')])],
version: static fn (): ?string => '5.3.0',
matrix: static fn () => throw new RuntimeException('unused'),
stateKey: static fn (string $version): string => 'key-' . $version,
extraSteps: DiviCheckSteps::extra(
$catalog ?? static fn (): array => [],
$format,
$probe ?? static fn (): array => []
)
);
return new CompatibilityCheck($profile, $manifests);
}
public function testTheDiviStepOrderIsUnchanged(): void
{
$report = $this->check([], [])->run(7);
self::assertTrue($report->green());
self::assertSame(
['manifest', 'mirrors', 'catalog', 'format', 'write'],
array_map(static fn (CheckStep $step): string => $step->key, $report->steps)
);
self::assertSame('Schreibtest auf einer Wegwerfseite', $report->step('write')?->label());
self::assertSame('Divi-Funktionen, Klassen und Registrierungen', $report->step('manifest')?->label());
self::assertSame('key-5.3.0', $report->stateKey);
}
public function testNoWriteTestWhenDiviFunctionsAreMissing(): void
{
$ran = false;
$probe = static function () use (&$ran): array {
$ran = true;
return [];
};
$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(DiviCheckSteps::WRITE)?->skipped);
self::assertFalse($ran);
}
public function testChangedFingerprintsStillRunTheWriteTest(): void
{
$report = $this->check([], ['TB: Nachbau von y()' => static fn (): bool => false])->run(7);
self::assertFalse($report->green());
self::assertTrue($report->step(DiviCheckSteps::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 (Details im PHP-Error-Log).'], $report->step(DiviCheckSteps::CATALOG)?->problems);
}
}
@@ -5,12 +5,15 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\ReleaseState; use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\Tier; use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters; use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviProfile; use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\DiviReleases;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Tests\Support\ReleaseMatrices; use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
@@ -32,6 +35,9 @@ final class DiviProfileTest extends TestCase
self::assertSame('divi', $profile->id); self::assertSame('divi', $profile->id);
self::assertSame('Divi', $profile->label); self::assertSame('Divi', $profile->label);
self::assertSame('divi_version', $profile->reportVersionKey, 'stored Divi data keeps its key');
self::assertSame(DiviProfile::ID, $profile->id);
self::assertSame(DiviProfile::REPORT_VERSION_KEY, $profile->reportVersionKey);
self::assertSame(Tier::ReleasePerVersion, $profile->tier); self::assertSame(Tier::ReleasePerVersion, $profile->tier);
self::assertSame(DiviFeature::cases(), $profile->capabilities); self::assertSame(DiviFeature::cases(), $profile->capabilities);
self::assertEquals(array_values(DiviAdapters::manifests()), $profile->manifests); self::assertEquals(array_values(DiviAdapters::manifests()), $profile->manifests);
@@ -45,14 +51,25 @@ final class DiviProfileTest extends TestCase
self::assertSame($status->matrix, (DiviProfile::profile()->matrix)()); self::assertSame($status->matrix, (DiviProfile::profile()->matrix)());
} }
public function testTheVersionComesFromTheDiviTemplateAndThereIsNoActiveCheck(): void public function testTheVersionComesFromTheDiviTemplateAndActiveMeansDiviIsInstalled(): void
{ {
$this->theme('Divi', '5.3.0'); $this->theme('Divi', '5.3.0');
$profile = DiviProfile::profile(); $profile = DiviProfile::profile();
self::assertNull($profile->active, 'Divi has its own component; DiviIntegration decides availability'); self::assertTrue(($profile->active)());
self::assertSame('5.3.0', ($profile->version)()); self::assertSame('5.3.0', ($profile->version)());
$this->theme('twentytwentyfive'); $this->theme('twentytwentyfive');
self::assertFalse(($profile->active)());
self::assertNull(($profile->version)()); self::assertNull(($profile->version)());
} }
public function testCarriesTestedVersionsStateKeyAndManifestCheck(): void
{
$this->theme('twentytwentyfive');
$profile = DiviProfile::profile();
self::assertSame(DiviReleases::TESTED, $profile->testedVersions);
self::assertSame(ReleaseResolver::stateKey('5.3.0'), ($profile->stateKey)('5.3.0'));
self::assertInstanceOf(ManifestCheck::class, ($profile->manifestCheck)());
}
} }
@@ -12,7 +12,7 @@ use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Problem; use LIT\MCP\Compatibility\Problem;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore; use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Plugin; use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Tests\Unit\TestCase;
final class ReleaseStoreTest extends TestCase
{
/** @var array<string, mixed> */
private array $options = [];
protected function setUp(): void
{
parent::setUp();
$this->options = [];
Functions\when('get_option')->alias(
fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default
);
Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool {
$this->options[$name] = $value;
self::assertFalse($autoload, 'releases are read only by the gate – no autoload');
return true;
});
}
public function testAddsFindsAndWithdrawsPerDiviAndPluginVersion(): void
{
$store = new ReleaseStore();
$store->add('5.3.0', '0.6.0', 1, 'r1');
$store->add('5.3.0', '0.6.0', 2, 'r2');
self::assertCount(1, $store->entries(), 'one entry per version pair');
self::assertSame(2, $store->find('5.3.0', '0.6.0')['user_id'] ?? null);
self::assertNull($store->find('5.3.0', '0.7.0'), 'another plugin version needs its own check');
self::assertTrue($store->withdraw('5.3.0', '0.6.0'));
self::assertFalse($store->withdraw('5.3.0', '0.6.0'));
self::assertSame([], $store->entries());
}
public function testIgnoresBrokenRows(): void
{
$this->options[ReleaseStore::OPTION] = ['x', ['divi_version' => '5.3.0'], [
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3,
'released_at' => 'd', 'report_id' => 'r',
]];
self::assertSame(['5.2.2'], array_column((new ReleaseStore())->entries(), 'divi_version'));
}
}
@@ -97,9 +97,18 @@ final class WriteProbeTest extends TestCase
throw new RuntimeException('Composer kaputt'); throw new RuntimeException('Composer kaputt');
}); });
$problems = (new WriteProbe($composer, $this->renderer(''), '/nonexistent-divi'))->run(); $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$problems = (new WriteProbe($composer, $this->renderer(''), '/nonexistent-divi'))->run();
$logged = (string) file_get_contents($log);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
self::assertSame(['Schreibtest abgebrochen: Composer kaputt'], $problems); self::assertSame(['Schreibtest abgebrochen (Details im PHP-Error-Log).'], $problems);
self::assertStringContainsString('Composer kaputt', $logged);
self::assertSame([41], $this->deleted); self::assertSame([41], $this->deleted);
$this->expectException(\LIT\MCP\Integrations\Divi\Release\DiviLocked::class); $this->expectException(\LIT\MCP\Integrations\Divi\Release\DiviLocked::class);
ReleaseGate::assertFeature(DiviFeature::PageWrite, 41); ReleaseGate::assertFeature(DiviFeature::PageWrite, 41);
@@ -112,8 +112,8 @@ final class IntegrationRegistryTest extends TestCase
public function testCompatibilitiesOfAllProvidersInRegistryOrderEvenUnavailable(): void public function testCompatibilitiesOfAllProvidersInRegistryOrderEvenUnavailable(): void
{ {
$first = new Profile('a', 'A', Tier::ReportOnly, []); $first = new Profile(id: 'a', label: 'A', tier: Tier::ReportOnly, capabilities: []);
$second = new Profile('b', 'B', Tier::ReportOnly, []); $second = new Profile(id: 'b', label: 'B', tier: Tier::ReportOnly, capabilities: []);
$provider = static function (string $id, bool $available, array $profiles): Integration { $provider = static function (string $id, bool $available, array $profiles): Integration {
$integration = Mockery::mock(Integration::class . ', ' . ProvidesCompatibility::class); $integration = Mockery::mock(Integration::class . ', ' . ProvidesCompatibility::class);
$integration->allows('id')->andReturn($id); $integration->allows('id')->andReturn($id);
+1
View File
@@ -35,6 +35,7 @@ final class PodsCompatibilityTest extends TestCase
self::assertSame('Pods', $profile->label); self::assertSame('Pods', $profile->label);
self::assertSame(Tier::LockOnFailure, $profile->tier); self::assertSame(Tier::LockOnFailure, $profile->tier);
self::assertSame('2.8', $profile->minVersion); self::assertSame('2.8', $profile->minVersion);
self::assertSame(['3.3.9.2'], $profile->testedVersions);
self::assertSame(PodsFeature::cases(), $profile->capabilities); self::assertSame(PodsFeature::cases(), $profile->capabilities);
} }
+4 -4
View File
@@ -145,10 +145,10 @@ final class PluginTest extends TestCase
private static function podsCompat(bool $usable): Compatibility private static function podsCompat(bool $usable): Compatibility
{ {
return new Compatibility([new Profile( return new Compatibility([new Profile(
'pods', id: 'pods',
'Pods', label: 'Pods',
Tier::LockOnFailure, tier: Tier::LockOnFailure,
PodsFeature::cases(), capabilities: PodsFeature::cases(),
active: static fn (): bool => $usable active: static fn (): bool => $usable
)]); )]);
} }
+2 -3
View File
@@ -11,10 +11,8 @@ use LIT\MCP\Access;
use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Limits; use LIT\MCP\Limits;
use LIT\MCP\Updater\RecheckOnInstall; use LIT\MCP\Updater\RecheckOnInstall;
use Mockery; use Mockery;
@@ -56,7 +54,8 @@ final class UninstallTest extends TestCase
$own = [ $own = [
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK, 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', DiviCheckRoute::LOCK,
'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock',
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css', 'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'), DesignLock::option('custom_css'), DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'), DesignLock::option('custom_css'),
+2 -3
View File
@@ -12,10 +12,8 @@ use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\Admin\LogTab; use LIT\MCP\Admin\LogTab;
use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Limits; use LIT\MCP\Limits;
use LIT\MCP\Uninstaller; use LIT\MCP\Uninstaller;
use LIT\MCP\Updater\RecheckOnInstall; use LIT\MCP\Updater\RecheckOnInstall;
@@ -62,7 +60,8 @@ final class UninstallerTest extends TestCase
self::assertSame( self::assertSame(
[ [
Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION,
ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK, 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', DiviCheckRoute::LOCK,
'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock',
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css', 'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'), DesignLock::option('custom_css'), DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'), DesignLock::option('custom_css'),