feat: Divi-Freigabe je Fähigkeit – CompatibilityMatrix aus Problemen mit Art und Fähigkeiten (ManifestCheck::problems), automatische Freigabe unveränderter Fähigkeiten (Filter lit_mcp_divi_auto_release), Mirror-Probleme im Transient (Format 2), ReleaseStatus trägt die Matrix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 22:05:08 +02:00
co-authored by Claude Opus 5.5
parent 710444e88d
commit c4efeb3ba4
17 changed files with 1222 additions and 72 deletions
+7
View File
@@ -225,6 +225,13 @@ final class DiviTab implements AdminTab
if ($status->source === ReleaseStatus::SOURCE_CODE) {
return __('Diese Version ist im Plugin als getestet hinterlegt.', 'wp-lit-mcp');
}
if ($status->source === ReleaseStatus::SOURCE_AUTO) {
return __(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Automatisch freigegeben: Alle Divi-Stellen, die LIT MCP nutzt, stimmen mit der geprüften Referenz überein.',
'wp-lit-mcp'
);
}
$user = get_userdata((int) ($status->release['user_id'] ?? 0));
$releasedAt = (int) strtotime((string) ($status->release['released_at'] ?? ''));
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* The release state of every Divi feature (E1). A feature the constructor did not get is incompatible (fail
* closed), so of() always answers. The overall state of the matrix is its worst feature state.
*/
final class CompatibilityMatrix
{
public const MISSING = 'Für diese Fähigkeit liegt kein Prüfergebnis vor.';
/** @var array<string, FeatureStatus> keyed by DiviFeature value, in DiviFeature::cases() order */
private array $features = [];
/** @param array<string, FeatureStatus> $features keyed by DiviFeature value */
public function __construct(array $features)
{
foreach (DiviFeature::cases() as $feature) {
$status = $features[$feature->value] ?? null;
$this->features[$feature->value] = $status instanceof FeatureStatus && $status->feature === $feature
? $status
: new FeatureStatus($feature, ReleaseState::Incompatible, [self::MISSING]);
}
}
public static function allReleased(string $source = ReleaseStatus::SOURCE_CODE): self
{
return self::uniform(ReleaseState::Released, [], $source);
}
public static function allIncompatible(string $problem): self
{
return self::uniform(ReleaseState::Incompatible, [$problem]);
}
/**
* Every feature in $state – the matrix of a status built without one (ReleaseStatus' constructor).
*
* @param list<string> $problems
*/
public static function uniform(ReleaseState $state, array $problems = [], ?string $source = null): self
{
$released = $state === ReleaseState::Released;
$features = [];
foreach (DiviFeature::cases() as $feature) {
$features[$feature->value] = new FeatureStatus(
$feature,
$state,
$released ? [] : $problems,
$released ? ($source ?? ReleaseStatus::SOURCE_CODE) : null
);
}
return new self($features);
}
/** @return list<DiviFeature> The features that write (ReleaseGate::assertWritable(), ReleaseStatus::writable()). */
public static function writeFeatures(): array
{
return array_values(array_filter(
DiviFeature::cases(),
static fn (DiviFeature $feature): bool => $feature->isWrite()
));
}
public function of(DiviFeature $feature): FeatureStatus
{
return $this->features[$feature->value];
}
/** @param list<DiviFeature> $features */
public function usable(array $features): bool
{
foreach ($features as $feature) {
if (! $this->of($feature)->usable()) {
return false;
}
}
return true;
}
/** @return list<FeatureStatus> */
public function unusable(): array
{
return array_values(array_filter(
$this->features,
static fn (FeatureStatus $status): bool => ! $status->usable()
));
}
/** Incompatible before untested before released. */
public function state(): ReleaseState
{
$states = array_map(static fn (FeatureStatus $status): ReleaseState => $status->state, $this->features);
return match (true) {
in_array(ReleaseState::Incompatible, $states, true) => ReleaseState::Incompatible,
in_array(ReleaseState::Untested, $states, true) => ReleaseState::Untested,
default => ReleaseState::Released,
};
}
/** The common source of a fully released matrix; null when not all released or the sources differ. */
public function source(): ?string
{
$sources = array_unique(array_map(
static fn (FeatureStatus $status): string => (string) $status->source,
$this->features
));
return $this->state() === ReleaseState::Released && count($sources) === 1 ? (string) reset($sources) : null;
}
/** @return list<string> Every problem label once, in feature order. */
public function problems(): array
{
$problems = [];
foreach ($this->features as $status) {
$problems = array_merge($problems, $status->problems);
}
return array_values(array_unique($problems));
}
/** @return list<array{id: string, label: string, state: string, problems: list<string>, source: ?string}> */
public function toArray(): array
{
return array_values(array_map(static fn (FeatureStatus $status): array => $status->toArray(), $this->features));
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** The release state of one Divi feature (E1): released (with its source), untested or incompatible. */
final class FeatureStatus
{
/**
* @param list<string> $problems Labels of the problems behind the state (Problem::$label).
* @param string|null $source For a released feature: ReleaseStatus::SOURCE_CODE, SOURCE_DATABASE or
* SOURCE_AUTO; null otherwise.
*/
public function __construct(
public readonly DiviFeature $feature,
public readonly ReleaseState $state,
public readonly array $problems = [],
public readonly ?string $source = null
) {
}
public function usable(): bool
{
return $this->state === ReleaseState::Released;
}
/** @return array{id: string, label: string, state: string, problems: list<string>} For refusals (D6). */
public function summary(): array
{
return [
'id' => $this->feature->value,
'label' => $this->feature->label(),
'state' => $this->state->value,
'problems' => $this->problems,
];
}
/**
* @return array{id: string, label: string, state: string, problems: list<string>, source: ?string}
* For lit-site-status and the admin tab.
*/
public function toArray(): array
{
return $this->summary() + ['source' => $this->source];
}
}
+115 -15
View File
@@ -13,8 +13,9 @@ use Throwable;
defined('ABSPATH') || exit;
/**
* The checks of all adapter manifests (spec §4.3), as label => check. Code checks (calls with the argument count
* the adapter passes, constants) and mirror checks decide the release gate; registration checks (post types,
* The checks of all adapter manifests (spec §4.3), as label => check for reports, and as problems with their Divi
* features for the release matrix (problems(), E1). Code checks (calls with the argument count the adapter passes
* and their signature, constants) and mirror checks decide the release gate; registration checks (post types,
* taxonomies, block types) depend on the request context (stage3 finding 6) and only show in reports.
*
* Not final so the resolver can be tested with a mock.
@@ -32,7 +33,8 @@ class ManifestCheck
/**
* @param array<string, Manifest>|null $manifests null = DiviAdapters::manifests().
* @param array<string, string>|null $expected Mirror ID => sha256; null = DiviReference::expected().
* @param array<string, string>|null $expected Mirror ID or "sig:<symbol>" => sha256;
* null = DiviReference::expected().
* @param (Closure(Mirror): ?string)|null $current Hash of the installed mirror, null = unreadable;
* null = SourceFingerprint in the Divi theme directory.
* @param (Closure(string): bool)|null $knowsBlock null = DiviModules::knowsBlock().
@@ -71,12 +73,7 @@ class ManifestCheck
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->mirrors as $mirror) {
$label = sprintf('%s: Nachbau von %s – %s', $manifest->area, $mirror->label(), $mirror->what);
$checks[$label] = function () use ($mirror): bool {
$expected = ($this->expected ??= DiviReference::expected())[$mirror->id] ?? null;
return $expected !== null && ($this->current)($mirror) === $expected;
};
$checks[self::mirrorLabel($manifest, $mirror)] = fn (): bool => $this->matches($mirror);
}
}
@@ -111,12 +108,7 @@ class ManifestCheck
{
$failed = [];
foreach ($checks as $label => $check) {
try {
$ok = (bool) $check();
} catch (Throwable) {
$ok = false;
}
if (! $ok) {
if (! self::passes($check)) {
$failed[] = (string) $label;
}
}
@@ -124,6 +116,67 @@ class ManifestCheck
return $failed;
}
/** @return list<Problem> Code and mirror problems with their features – what the release matrix is built of. */
public function problems(): array
{
return array_merge($this->codeProblems(), $this->mirrorProblems());
}
/**
* Calls (missing or other argument count: call; no reference: reference-missing; other signature: signature)
* and constants. Reflection only – cheap enough for every request.
*
* @return list<Problem>
*/
public function codeProblems(): array
{
$problems = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->calls as $call) {
$problem = $this->callProblem($manifest, $call);
if ($problem !== null) {
$problems[] = $problem;
}
}
foreach ($manifest->constants as $constant) {
if (! defined($constant->name)) {
$problems[] = new Problem(
$manifest->area . ': ' . $constant->label(),
$manifest->featuresOf($constant),
Problem::CONSTANT
);
}
}
}
return $problems;
}
/**
* Mirrors that differ from their reference, cannot be read or have no reference – kind mirror in every case:
* a mirror repeats how Divi writes, so it never runs unverified, whatever the release. Reads Divi files; the
* resolver caches the result.
*
* @return list<Problem>
*/
public function mirrorProblems(): array
{
$problems = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->mirrors as $mirror) {
if (! self::passes(fn (): bool => $this->matches($mirror))) {
$problems[] = new Problem(
self::mirrorLabel($manifest, $mirror),
$manifest->featuresOf($mirror),
Problem::MIRROR
);
}
}
}
return $problems;
}
/** The function or method exists and takes the number of arguments the adapter passes. */
public static function accepts(Call $call): bool
{
@@ -145,6 +198,53 @@ class ManifestCheck
&& ($reflection->isVariadic() || $reflection->getNumberOfParameters() >= $call->args));
}
private function callProblem(Manifest $manifest, Call $call): ?Problem
{
$label = $manifest->area . ': ' . $call->label();
$features = $manifest->featuresOf($call);
if (! self::passes(static fn (): bool => self::accepts($call))) {
return new Problem($label, $features, Problem::CALL);
}
$expected = $this->expected()[DiviReference::signatureKey($call)] ?? null;
if ($expected === null) {
return new Problem($label . ' – Referenz der Signatur fehlt', $features, Problem::REFERENCE_MISSING);
}
return self::passes(static fn (): bool => SignatureFingerprint::of($call) === $expected)
? null
: new Problem($label . ' – Signatur geändert', $features, Problem::SIGNATURE);
}
/** The mirror's reference exists and the installed source has the same fingerprint. */
private function matches(Mirror $mirror): bool
{
$expected = $this->expected()[$mirror->id] ?? null;
return $expected !== null && ($this->current)($mirror) === $expected;
}
/** @return array<string, string> */
private function expected(): array
{
return $this->expected ??= DiviReference::expected();
}
private static function mirrorLabel(Manifest $manifest, Mirror $mirror): string
{
return sprintf('%s: Nachbau von %s – %s', $manifest->area, $mirror->label(), $mirror->what);
}
/** @param callable(): bool $check A check that throws failed. */
private static function passes(callable $check): bool
{
try {
return (bool) $check();
} catch (Throwable) {
return false;
}
}
private static function installed(Mirror $mirror): ?string
{
$dir = DiviEnvironment::themeDir();
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* One failed manifest check and the Divi features it concerns (Manifest::featuresOf()). The kind decides how the
* resolver weighs it (E1): call, mirror and constant lock their features for every Divi version, released or not;
* signature and reference-missing only matter for a version without release (automatic release). A problem without
* features concerns every feature (fail closed).
*/
final class Problem
{
public const CALL = 'call';
public const SIGNATURE = 'signature';
public const MIRROR = 'mirror';
public const CONSTANT = 'constant';
public const REFERENCE_MISSING = 'reference-missing';
public const RESOLVER = 'resolver';
private const KINDS = [
self::CALL,
self::SIGNATURE,
self::MIRROR,
self::CONSTANT,
self::REFERENCE_MISSING,
self::RESOLVER,
];
private const HARD = [self::CALL, self::MIRROR, self::CONSTANT, self::RESOLVER];
/**
* @param string $label German, e.g. "Seiten: et_x()" (the labels of ManifestCheck).
* @param list<DiviFeature> $features
* @param string $kind One of the constants above.
*/
public function __construct(
public readonly string $label,
public readonly array $features,
public readonly string $kind
) {
}
/** Locks its features whatever the release (the code no longer fits Divi or a mirror differs). */
public function isHard(): bool
{
return in_array($this->kind, self::HARD, true);
}
public function concerns(DiviFeature $feature): bool
{
return $this->features === [] || in_array($feature, $this->features, true);
}
/** @return array{label: string, kind: string, features: list<string>} For the transient. */
public function toArray(): array
{
return [
'label' => $this->label,
'kind' => $this->kind,
'features' => array_map(static fn (DiviFeature $feature): string => $feature->value, $this->features),
];
}
/** The problem of toArray(), or null for anything else (an unknown kind or feature included). */
public static function fromArray(mixed $row): ?self
{
if (
! is_array($row)
|| ! is_string($row['label'] ?? null)
|| ! in_array($row['kind'] ?? null, self::KINDS, true)
|| ! is_array($row['features'] ?? null)
) {
return null;
}
$features = [];
foreach ($row['features'] as $value) {
$feature = is_string($value) ? DiviFeature::tryFrom($value) : null;
if ($feature === null) {
return null;
}
$features[] = $feature;
}
return new self($row['label'], $features, $row['kind']);
}
}
@@ -24,7 +24,9 @@ final class ReleaseGate
private static ?int $probe = null;
/** Spec §9: a status that cannot be determined locks writes like an incompatible Divi (and is logged). */
private const UNRESOLVED = 'Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).';
/** Spec §9: a status that cannot be determined locks every feature as incompatible (and is logged). */
public static function status(): ReleaseStatus
{
if (self::$status !== null) {
@@ -35,12 +37,7 @@ final class ReleaseGate
self::$status = (self::$resolver ?? static fn (): ReleaseStatus => (new ReleaseResolver())->resolve())();
} catch (Throwable $e) {
Log::failure('divi release status', $e);
self::$status = new ReleaseStatus(
ReleaseState::Incompatible,
'',
null,
['Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).']
);
self::$status = ReleaseStatus::of('', CompatibilityMatrix::allIncompatible(self::UNRESOLVED));
}
return self::$status;
+121 -21
View File
@@ -10,13 +10,24 @@ use LIT\MCP\Plugin;
defined('ABSPATH') || exit;
/**
* Computes the release status (spec §5.2–§5.4): code checks live (cheap), mirror checks cached in a transient under
* stateKey() – Divi version, plugin version, fingerprints.json and the modification times of the mirrored Divi
* files, so a Divi update or hotfix is checked again at once.
* Computes the release status (spec §5.2–§5.4) as a matrix of Divi features (E1). Code and signature checks run
* live (Reflection, cheap; a hotfix without a new version number is seen at once), mirror checks are cached in a
* transient under stateKey() – Divi version, plugin version, fingerprints.json (mirror and signature references) and
* the modification times of the mirrored Divi files.
*
* Per feature: a hard problem (call, mirror, constant) makes it incompatible, released version or not. A released
* version (DiviReleases::TESTED, source code; ReleaseStore, source database) releases every other feature – its
* release covers signatures. Otherwise, with the automatic release on (filter lit_mcp_divi_auto_release, default
* true) and a known version: a changed signature makes the feature incompatible, a missing reference untested,
* no problem releases it (source auto). Without the automatic release every other feature is untested.
*/
final class ReleaseResolver
{
public const CACHE = 'lit_mcp_divi_state';
public const AUTO_RELEASE_FILTER = 'lit_mcp_divi_auto_release';
/** Version of the transient's layout; an entry without it (the list of labels before) is a miss. */
private const CACHE_FORMAT = 2;
/**
* @param (Closure(): ?string)|null $version null = DiviEnvironment::version().
@@ -34,22 +45,17 @@ final class ReleaseResolver
{
$version = ($this->version ?? DiviEnvironment::version(...))() ?? '';
$check = $this->check ?? new ManifestCheck();
$problems = array_merge(
ManifestCheck::failed($check->codeChecks()),
$this->mirrorProblems($check, $version)
);
$problems = array_merge($check->codeProblems(), $this->mirrorProblems($check, $version));
if ($problems !== []) {
return new ReleaseStatus(ReleaseState::Incompatible, $version, null, $problems);
}
if (in_array($version, DiviReleases::TESTED, true)) {
return ReleaseStatus::released($version);
return ReleaseStatus::of($version, self::matrix($problems, ReleaseStatus::SOURCE_CODE, false));
}
$entry = $version === '' ? null : ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION);
if ($entry !== null) {
return ReleaseStatus::of($version, self::matrix($problems, ReleaseStatus::SOURCE_DATABASE, false), $entry);
}
$entry = ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION);
return $entry !== null
? new ReleaseStatus(ReleaseState::Released, $version, ReleaseStatus::SOURCE_DATABASE, [], $entry)
: new ReleaseStatus(ReleaseState::Untested, $version);
return ReleaseStatus::of($version, self::matrix($problems, null, $version !== '' && self::automatic()));
}
/** What the cached mirror results and a check report are valid for. */
@@ -69,17 +75,111 @@ final class ReleaseResolver
return hash('sha256', implode('|', $parts));
}
/** @return list<string> */
/** Only a literal true switches the automatic release on (fail closed). */
private static function automatic(): bool
{
return apply_filters(self::AUTO_RELEASE_FILTER, true) === true;
}
/**
* @param list<Problem> $problems
* @param string|null $source SOURCE_CODE or SOURCE_DATABASE for a released version, else null.
* @param bool $automatic The automatic release applies (no release, known version, filter on).
*/
private static function matrix(array $problems, ?string $source, bool $automatic): CompatibilityMatrix
{
$features = [];
foreach (DiviFeature::cases() as $feature) {
$concerning = array_values(array_filter(
$problems,
static fn (Problem $problem): bool => $problem->concerns($feature)
));
$features[$feature->value] = self::feature($feature, $concerning, $source, $automatic);
}
return new CompatibilityMatrix($features);
}
/** @param list<Problem> $problems The problems concerning $feature. */
private static function feature(
DiviFeature $feature,
array $problems,
?string $source,
bool $automatic
): FeatureStatus {
$hard = array_filter($problems, static fn (Problem $problem): bool => $problem->isHard());
if ($hard !== []) {
return new FeatureStatus($feature, ReleaseState::Incompatible, self::labels($hard));
}
if ($source !== null) {
return new FeatureStatus($feature, ReleaseState::Released, [], $source);
}
if (! $automatic) {
return new FeatureStatus($feature, ReleaseState::Untested, self::labels($problems));
}
$changed = array_filter($problems, static fn (Problem $problem): bool => $problem->kind === Problem::SIGNATURE);
if ($changed !== []) {
return new FeatureStatus($feature, ReleaseState::Incompatible, self::labels($changed));
}
return $problems === []
? new FeatureStatus($feature, ReleaseState::Released, [], ReleaseStatus::SOURCE_AUTO)
: new FeatureStatus($feature, ReleaseState::Untested, self::labels($problems));
}
/**
* @param array<int, Problem> $problems
* @return list<string>
*/
private static function labels(array $problems): array
{
return array_values(array_map(static fn (Problem $problem): string => $problem->label, $problems));
}
/** @return list<Problem> */
private function mirrorProblems(ManifestCheck $check, string $version): array
{
$key = ($this->stateKey ?? self::stateKey(...))($version);
$cached = get_transient(self::CACHE);
if (is_array($cached) && ($cached['key'] ?? null) === $key && is_array($cached['problems'] ?? null)) {
return array_values(array_filter($cached['problems'], 'is_string'));
$cached = self::cached(get_transient(self::CACHE), $key);
if ($cached !== null) {
return $cached;
}
$problems = ManifestCheck::failed($check->mirrorChecks());
set_transient(self::CACHE, ['key' => $key, 'problems' => $problems], DAY_IN_SECONDS);
$problems = $check->mirrorProblems();
set_transient(
self::CACHE,
[
'format' => self::CACHE_FORMAT,
'key' => $key,
'problems' => array_map(static fn (Problem $problem): array => $problem->toArray(), $problems),
],
DAY_IN_SECONDS
);
return $problems;
}
/** @return list<Problem>|null null = miss: none, another key, the old format or any unreadable row. */
private static function cached(mixed $entry, string $key): ?array
{
if (
! is_array($entry)
|| ($entry['format'] ?? null) !== self::CACHE_FORMAT
|| ($entry['key'] ?? null) !== $key
|| ! is_array($entry['problems'] ?? null)
) {
return null;
}
$problems = [];
foreach ($entry['problems'] as $row) {
$problem = Problem::fromArray($row);
if ($problem === null) {
return null;
}
$problems[] = $problem;
}
return $problems;
}
@@ -10,11 +10,15 @@ use WP_Error;
defined('ABSPATH') || exit;
/** Whether LIT MCP may write with the installed Divi (spec §5.2), with the texts of spec §5.6. */
/**
* The release of the installed Divi (spec §5.2): the matrix of its features (E1) and, for callers that know no
* features, the overall state (the worst feature state) with every problem, and the texts of spec §5.6.
*/
final class ReleaseStatus
{
public const SOURCE_CODE = 'code';
public const SOURCE_DATABASE = 'database';
public const SOURCE_AUTO = 'auto';
private const UNTESTED = 'Achtung: %s ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende '
. 'Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung '
@@ -27,19 +31,35 @@ final class ReleaseStatus
. 'Divi-Werkzeuge funktionieren weiter.';
private const NOTICE = 'Achtung: %s ist für LIT MCP %s; Lesen funktioniert, Schreiben ist gesperrt.';
public readonly CompatibilityMatrix $matrix;
/**
* Without $matrix every feature is in $state (CompatibilityMatrix::uniform()); with one, use of().
*
* @param string $version Divi version, '' when unknown (Divi is not the active template).
* @param string|null $source SOURCE_CODE or SOURCE_DATABASE for a released version.
* @param string|null $source SOURCE_CODE, SOURCE_DATABASE or SOURCE_AUTO for a released version.
* @param list<string> $problems Failed code and mirror checks (ManifestCheck labels).
* @param array<string, mixed>|null $release The database entry of a SOURCE_DATABASE release.
* @throws LogicException When $matrix is in another state than $state.
*/
public function __construct(
public readonly ReleaseState $state,
public readonly string $version,
public readonly ?string $source = null,
public readonly array $problems = [],
public readonly ?array $release = null
public readonly ?array $release = null,
?CompatibilityMatrix $matrix = null
) {
if ($matrix !== null && $matrix->state() !== $state) {
throw new LogicException('the matrix is ' . $matrix->state()->value . ', not ' . $state->value);
}
$this->matrix = $matrix ?? CompatibilityMatrix::uniform($state, $problems, $source);
}
/** @param array<string, mixed>|null $release The database entry of a SOURCE_DATABASE release. */
public static function of(string $version, CompatibilityMatrix $matrix, ?array $release = null): self
{
return new self($matrix->state(), $version, $matrix->source(), $matrix->problems(), $release, $matrix);
}
public static function released(string $version, string $source = self::SOURCE_CODE): self
@@ -47,9 +67,10 @@ final class ReleaseStatus
return new self(ReleaseState::Released, $version, $source);
}
/** Every writing feature is usable – for callers that know no feature (DiviToolGate, DiviContentComposer). */
public function writable(): bool
{
return $this->state === ReleaseState::Released;
return $this->matrix->usable(CompatibilityMatrix::writeFeatures());
}
public function versionLabel(): string
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Support;
use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
/** Release statuses for tests: every feature released except the named ones. */
final class ReleaseMatrices
{
/** @param list<DiviFeature> $locked */
public static function matrix(array $locked, ReleaseState $state = ReleaseState::Untested): CompatibilityMatrix
{
$features = [];
foreach (DiviFeature::cases() as $feature) {
$features[$feature->value] = in_array($feature, $locked, true)
? new FeatureStatus(
$feature,
$state,
$state === ReleaseState::Incompatible ? ['Seiten: et_x() – Signatur geändert'] : []
)
: new FeatureStatus($feature, ReleaseState::Released, [], ReleaseStatus::SOURCE_CODE);
}
return new CompatibilityMatrix($features);
}
/** @param list<DiviFeature> $locked */
public static function status(
array $locked,
ReleaseState $state = ReleaseState::Untested,
string $version = '5.3.0'
): ReleaseStatus {
return ReleaseStatus::of($version, self::matrix($locked, $state));
}
}
+9
View File
@@ -215,6 +215,15 @@ final class DiviTabTest extends TestCase
self::assertStringNotContainsString('Freigabe zurückziehen', $html);
}
public function testAnAutomaticReleaseSaysSoAndOffersNeitherReleaseNorWithdrawal(): void
{
$html = $this->rendered(ReleaseStatus::released('5.99.0', ReleaseStatus::SOURCE_AUTO), self::report());
self::assertStringContainsString('Automatisch freigegeben', $html);
self::assertStringNotContainsString('freigeben</button>', $html);
self::assertStringNotContainsString('Freigabe zurückziehen', $html);
}
public function testADatabaseReleaseCanBeWithdrawn(): void
{
$status = new ReleaseStatus(ReleaseState::Released, '5.3.0', ReleaseStatus::SOURCE_DATABASE, [], [
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use PHPUnit\Framework\TestCase;
final class CompatibilityMatrixTest extends TestCase
{
public function testAFeatureWithoutResultIsIncompatible(): void
{
$matrix = new CompatibilityMatrix([
'page-read' => new FeatureStatus(DiviFeature::PageRead, ReleaseState::Released, [], 'code'),
// Under the wrong key: does not count for page-write.
'page-write' => new FeatureStatus(DiviFeature::PageRead, ReleaseState::Released, [], 'code'),
]);
self::assertTrue($matrix->of(DiviFeature::PageRead)->usable());
foreach ([DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite] as $feature) {
$status = $matrix->of($feature);
self::assertSame(ReleaseState::Incompatible, $status->state);
self::assertSame([CompatibilityMatrix::MISSING], $status->problems);
}
self::assertCount(count(DiviFeature::cases()) - 1, $matrix->unusable());
}
public function testAllReleasedAndAllIncompatible(): void
{
$released = CompatibilityMatrix::allReleased('database');
$broken = CompatibilityMatrix::allIncompatible('kaputt');
foreach (DiviFeature::cases() as $feature) {
self::assertTrue($released->of($feature)->usable());
self::assertSame('database', $released->of($feature)->source);
self::assertSame(ReleaseState::Incompatible, $broken->of($feature)->state);
self::assertSame(['kaputt'], $broken->of($feature)->problems);
self::assertNull($broken->of($feature)->source);
}
self::assertSame([], $released->unusable());
self::assertSame('code', CompatibilityMatrix::allReleased()->source());
}
public function testTheStateIsTheWorstFeatureAndEveryProblemIsListedOnce(): void
{
$matrix = self::matrix([
'page-write' => [ReleaseState::Untested, ['a']],
'tb-write' => [ReleaseState::Incompatible, ['b', 'a']],
]);
self::assertSame(ReleaseState::Incompatible, $matrix->state());
self::assertSame(['a', 'b'], $matrix->problems());
self::assertNull($matrix->source());
self::assertSame(ReleaseState::Untested, self::matrix(['catalog' => [ReleaseState::Untested, []]])->state());
self::assertSame(ReleaseState::Released, self::matrix([])->state());
}
public function testTheSourceOfAReleasedMatrixIsCommonOrNull(): void
{
self::assertSame('auto', self::matrix([])->source());
$mixed = new CompatibilityMatrix(array_merge(
self::features([]),
['roles' => new FeatureStatus(DiviFeature::Roles, ReleaseState::Released, [], 'code')]
));
self::assertSame(ReleaseState::Released, $mixed->state());
self::assertNull($mixed->source());
}
public function testUsableAndUnusable(): void
{
$matrix = self::matrix(['catalog' => [ReleaseState::Untested, []]]);
self::assertTrue($matrix->usable([DiviFeature::PageWrite, DiviFeature::PageRead]));
self::assertFalse($matrix->usable([DiviFeature::PageWrite, DiviFeature::Catalog]));
self::assertTrue($matrix->usable([]));
self::assertSame(
[DiviFeature::Catalog],
array_map(static fn (FeatureStatus $status): DiviFeature => $status->feature, $matrix->unusable())
);
}
public function testTheWriteFeaturesAreTheWritingCases(): void
{
self::assertSame(
array_values(array_filter(DiviFeature::cases(), static fn (DiviFeature $f): bool => $f->isWrite())),
CompatibilityMatrix::writeFeatures()
);
self::assertContains(DiviFeature::PageWrite, CompatibilityMatrix::writeFeatures());
self::assertNotContains(DiviFeature::PageRead, CompatibilityMatrix::writeFeatures());
}
public function testToArrayListsEveryFeatureInCaseOrder(): void
{
$rows = self::matrix(['tb-write' => [ReleaseState::Incompatible, ['b']]])->toArray();
self::assertSame(
array_map(static fn (DiviFeature $feature): string => $feature->value, DiviFeature::cases()),
array_column($rows, 'id')
);
self::assertContains(
['id' => 'tb-write', 'label' => DiviFeature::ThemeBuilderWrite->label(), 'state' => 'incompatible',
'problems' => ['b'], 'source' => null],
$rows
);
}
/**
* @param array<string, array{ReleaseState, list<string>}> $locked
* @return array<string, FeatureStatus>
*/
private static function features(array $locked): array
{
$features = [];
foreach (DiviFeature::cases() as $feature) {
[$state, $problems] = $locked[$feature->value] ?? [ReleaseState::Released, []];
$features[$feature->value] = new FeatureStatus(
$feature,
$state,
$problems,
$state === ReleaseState::Released ? 'auto' : null
);
}
return $features;
}
/** @param array<string, array{ReleaseState, list<string>}> $locked */
private static function matrix(array $locked): CompatibilityMatrix
{
return new CompatibilityMatrix(self::features($locked));
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use PHPUnit\Framework\TestCase;
final class FeatureStatusTest extends TestCase
{
public function testOnlyAReleasedFeatureIsUsable(): void
{
self::assertTrue((new FeatureStatus(DiviFeature::PageRead, ReleaseState::Released, [], 'auto'))->usable());
self::assertFalse((new FeatureStatus(DiviFeature::PageRead, ReleaseState::Untested))->usable());
self::assertFalse((new FeatureStatus(DiviFeature::PageRead, ReleaseState::Incompatible, ['x']))->usable());
}
public function testSummaryForRefusalsAndToArrayWithTheSource(): void
{
$status = new FeatureStatus(DiviFeature::PresetWrite, ReleaseState::Incompatible, ['Designsystem: x()']);
self::assertSame(
['id' => 'preset-write', 'label' => DiviFeature::PresetWrite->label(), 'state' => 'incompatible',
'problems' => ['Designsystem: x()']],
$status->summary()
);
self::assertSame($status->summary() + ['source' => null], $status->toArray());
}
}
@@ -7,9 +7,13 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use ArrayObject;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\ConstantRequirement;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\Problem;
use LIT\MCP\Integrations\Divi\Release\SignatureFingerprint;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
@@ -131,4 +135,101 @@ final class ManifestCheckTest extends TestCase
ManifestCheck::failed($checks)
);
}
/** A manifest with one requirement per problem kind; the default feature is page-read. */
private function featureCheck(?\Closure $current = null): ManifestCheck
{
$manifest = new Manifest(
Manifest::PAGES,
calls: [
new Call('strlen', 1),
new Call('str_replace', 3, [DiviFeature::PageWrite]),
new Call('trim', 1),
new Call('lit_mcp_missing_function', 0, [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite]),
],
constants: [
new ConstantRequirement('PHP_VERSION'),
new ConstantRequirement('LIT_MCP_MISSING_CONSTANT', [DiviFeature::Catalog]),
],
postTypes: ['lit_mcp_missing_type'],
mirrors: [
new Mirror('same', 'a.php', 'a', null, 'gleich'),
new Mirror('changed', 'b.php', 'b', null, 'geändert', [DiviFeature::ThemeBuilderWrite]),
new Mirror('no-expectation', 'd.php', 'd', null, 'ohne Soll'),
],
features: [DiviFeature::PageRead]
);
$hashes = ['same' => 'h1', 'changed' => 'hX', 'no-expectation' => 'h4'];
return new ManifestCheck(
['A' => $manifest],
[
'same' => 'h1',
'changed' => 'h2',
'sig:strlen' => (string) SignatureFingerprint::of(new Call('strlen', 1)),
'sig:str_replace' => 'a different signature',
],
$current ?? static fn (Mirror $mirror): ?string => $hashes[$mirror->id] ?? null,
static fn (string $name): bool => false
);
}
/**
* @param list<Problem> $problems
* @return list<array{label: string, kind: string, features: list<string>}>
*/
private static function rows(array $problems): array
{
return array_map(static fn (Problem $problem): array => $problem->toArray(), $problems);
}
public function testCodeProblemsNameKindAndFeaturesOfEveryFailedCall(): void
{
self::assertSame(
[
['label' => 'Seiten: str_replace() – Signatur geändert', 'kind' => 'signature',
'features' => ['page-write']],
['label' => 'Seiten: trim() – Referenz der Signatur fehlt', 'kind' => 'reference-missing',
'features' => ['page-read']],
['label' => 'Seiten: lit_mcp_missing_function()', 'kind' => 'call',
'features' => ['page-write', 'tb-write']],
['label' => 'Seiten: Konstante LIT_MCP_MISSING_CONSTANT', 'kind' => 'constant',
'features' => ['catalog']],
],
self::rows($this->featureCheck(static function (): never {
self::fail('code problems never read Divi files');
})->codeProblems())
);
}
public function testAMirrorWithoutReferenceIsAMirrorProblemLikeAChangedOne(): void
{
self::assertSame(
[
['label' => 'Seiten: Nachbau von b() – geändert', 'kind' => 'mirror', 'features' => ['tb-write']],
['label' => 'Seiten: Nachbau von d() – ohne Soll', 'kind' => 'mirror', 'features' => ['page-read']],
],
self::rows($this->featureCheck()->mirrorProblems())
);
}
public function testAnUnreadableMirrorIsAMirrorProblem(): void
{
$problems = $this->featureCheck(static function (): never {
throw new RuntimeException('Divi file gone');
})->mirrorProblems();
self::assertSame(['mirror', 'mirror', 'mirror'], array_column(self::rows($problems), 'kind'));
}
public function testProblemsAreCodeThenMirrorsAndRegistrationIsNone(): void
{
$check = $this->featureCheck();
self::assertEquals(array_merge($check->codeProblems(), $check->mirrorProblems()), $check->problems());
self::assertNotContains(
'Seiten: Post-Typ lit_mcp_missing_type',
array_column(self::rows($check->problems()), 'label')
);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Problem;
use PHPUnit\Framework\TestCase;
final class ProblemTest extends TestCase
{
public function testOnlyCallMirrorConstantAndResolverAreHard(): void
{
$hard = [];
foreach (['call', 'signature', 'mirror', 'constant', 'reference-missing', 'resolver'] as $kind) {
$hard[$kind] = (new Problem('x', [DiviFeature::PageWrite], $kind))->isHard();
}
self::assertSame(
['call' => true, 'signature' => false, 'mirror' => true, 'constant' => true,
'reference-missing' => false, 'resolver' => true],
$hard
);
}
public function testConcernsItsFeaturesAndWithoutFeaturesEveryFeature(): void
{
$problem = new Problem('x', [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], Problem::CALL);
self::assertTrue($problem->concerns(DiviFeature::ThemeBuilderWrite));
self::assertFalse($problem->concerns(DiviFeature::PageRead));
$unassigned = new Problem('x', [], Problem::CALL);
foreach (DiviFeature::cases() as $feature) {
self::assertTrue($unassigned->concerns($feature), $feature->value);
}
}
public function testRoundTripsThroughTheCacheFormat(): void
{
$problem = new Problem('Seiten: Nachbau von a() – x', [DiviFeature::PageWrite], Problem::MIRROR);
self::assertSame(
['label' => 'Seiten: Nachbau von a() – x', 'kind' => 'mirror', 'features' => ['page-write']],
$problem->toArray()
);
self::assertEquals($problem, Problem::fromArray($problem->toArray()));
}
public function testFromArrayRefusesAnythingElse(): void
{
foreach ([
'no array' => 'Seiten: x()',
'no label' => ['kind' => 'call', 'features' => []],
'unknown kind' => ['label' => 'x', 'kind' => 'other', 'features' => []],
'unknown feature' => ['label' => 'x', 'kind' => 'call', 'features' => ['page-delete']],
'features no list' => ['label' => 'x', 'kind' => 'call', 'features' => 'page-write'],
] as $case => $row) {
self::assertNull(Problem::fromArray($row), $case);
}
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
@@ -109,6 +110,11 @@ final class ReleaseGateTest extends TestCase
}
self::assertSame(ReleaseState::Incompatible, $status->state);
self::assertCount(count(DiviFeature::cases()), $status->matrix->unusable(), 'every feature is locked');
self::assertSame(
['Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).'],
$status->matrix->of(DiviFeature::PageRead)->problems
);
self::assertStringContainsString('theme API gone', (string) file_get_contents($log));
unlink($log);
$this->expectException(DiviLocked::class);
@@ -4,10 +4,14 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Problem;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase;
@@ -15,13 +19,21 @@ use Mockery;
final class ReleaseResolverTest extends TestCase
{
private const UNKNOWN = '5.99.0';
/** @var array<string, mixed> */
private array $transients = [];
private int $mirrorRuns = 0;
private int $codeRuns = 0;
protected function setUp(): void
{
parent::setUp();
$this->transients = [];
$this->mirrorRuns = 0;
$this->codeRuns = 0;
Functions\when('get_transient')->alias(fn (string $key): mixed => $this->transients[$key] ?? false);
Functions\when('set_transient')->alias(function (string $key, mixed $value): bool {
$this->transients[$key] = $value;
@@ -31,8 +43,9 @@ final class ReleaseResolverTest extends TestCase
}
/**
* @param array<string, callable(): bool> $code
* @param array<string, callable(): bool> $mirrors
* @param list<Problem> $code
* @param list<Problem> $mirrors
* @param array<string, mixed>|null $entry
*/
private function resolver(
string $version,
@@ -41,8 +54,16 @@ final class ReleaseResolverTest extends TestCase
?array $entry = null
): ReleaseResolver {
$check = Mockery::mock(ManifestCheck::class);
$check->allows('codeChecks')->andReturn($code);
$check->allows('mirrorChecks')->andReturn($mirrors);
$check->allows('codeProblems')->andReturnUsing(function () use ($code): array {
++$this->codeRuns;
return $code;
});
$check->allows('mirrorProblems')->andReturnUsing(function () use ($mirrors): array {
++$this->mirrorRuns;
return $mirrors;
});
$store = Mockery::mock(ReleaseStore::class);
$store->allows('find')->with($version, Plugin::VERSION)->andReturn($entry);
@@ -54,54 +75,246 @@ final class ReleaseResolverTest extends TestCase
);
}
public function testATestedVersionWithGreenChecksIsReleasedFromCode(): void
/** @param list<DiviFeature> $features */
private static function problem(string $kind, array $features, string $label = 'Seiten: et_x()'): Problem
{
return new Problem($label, $features, $kind);
}
/** @return array<string, string> Feature value => state, for every feature. */
private static function states(ReleaseStatus $status): array
{
$states = [];
foreach (DiviFeature::cases() as $feature) {
$states[$feature->value] = $status->matrix->of($feature)->state->value;
}
return $states;
}
/**
* @param array<string, string> $except Feature value => state.
* @return array<string, string>
*/
private static function allBut(string $state, array $except = []): array
{
$states = [];
foreach (DiviFeature::cases() as $feature) {
$states[$feature->value] = $except[$feature->value] ?? $state;
}
return $states;
}
public function testATestedVersionWithoutProblemsIsReleasedFromCode(): void
{
$status = $this->resolver('5.2.1')->resolve();
self::assertSame(ReleaseState::Released, $status->state);
self::assertSame('code', $status->source);
self::assertSame(self::allBut('released'), self::states($status));
self::assertSame('code', $status->matrix->of(DiviFeature::PageWrite)->source);
}
public function testAnUnknownVersionIsUntestedUnlessTheDatabaseReleasedIt(): void
public function testAReleaseCoversChangedSignaturesAndMissingReferences(): void
{
self::assertSame(ReleaseState::Untested, $this->resolver('5.3.0')->resolve()->state);
$status = $this->resolver('5.2.1', [
self::problem(Problem::SIGNATURE, [DiviFeature::PageWrite]),
self::problem(Problem::REFERENCE_MISSING, [DiviFeature::Catalog]),
])->resolve();
$released = $this->resolver('5.3.0', entry: ['user_id' => 1, 'released_at' => 'd'])->resolve();
self::assertSame(ReleaseState::Released, $released->state);
self::assertSame('database', $released->source);
self::assertSame(self::allBut('released'), self::states($status));
self::assertTrue($status->writable());
self::assertSame([], $status->problems);
}
public function testAFailedCheckMakesEvenATestedVersionIncompatible(): void
public function testAHardProblemLocksItsFeaturesEvenForATestedVersion(): void
{
$status = $this->resolver('5.2.1', ['Seiten: et_x()' => static fn (): bool => false])->resolve();
$status = $this->resolver('5.2.1', [
self::problem(Problem::CALL, [DiviFeature::PageWrite], 'Seiten: et_x()'),
self::problem(Problem::CONSTANT, [DiviFeature::Catalog], 'Katalog: Konstante X'),
])->resolve();
self::assertSame(
self::allBut('released', ['page-write' => 'incompatible', 'catalog' => 'incompatible']),
self::states($status)
);
self::assertSame(['Seiten: et_x()'], $status->matrix->of(DiviFeature::PageWrite)->problems);
self::assertSame(ReleaseState::Incompatible, $status->state);
self::assertSame(['Seiten: et_x()'], $status->problems);
self::assertSame(['Seiten: et_x()', 'Katalog: Konstante X'], $status->problems);
self::assertFalse($status->writable());
}
public function testMirrorResultsAreCachedByStateKey(): void
public function testAProblemWithoutFeaturesLocksEveryFeature(): void
{
$calls = 0;
$mirrors = ['Theme Builder: Nachbau von y()' => static function () use (&$calls): bool {
++$calls;
$status = $this->resolver('5.2.1', [self::problem(Problem::CALL, [])])->resolve();
return false;
}];
self::assertSame(self::allBut('incompatible'), self::states($status));
}
/** Review focus 2: a changed mirror locks exactly its features – released version or automatic release. */
public function testAChangedMirrorLocksExactlyItsFeatures(): void
{
$mirror = self::problem(
Problem::MIRROR,
[DiviFeature::ThemeBuilderWrite, DiviFeature::LibraryWrite],
'Theme Builder: Nachbau von y() – z'
);
$expected = ['tb-write' => 'incompatible', 'library-write' => 'incompatible'];
self::assertSame(
self::allBut('released', $expected),
self::states($this->resolver('5.2.1', mirrors: [$mirror])->resolve())
);
$automatic = $this->resolver(self::UNKNOWN, mirrors: [$mirror])->resolve();
self::assertSame(self::allBut('released', $expected), self::states($automatic));
self::assertSame(
['Theme Builder: Nachbau von y() – z'],
$automatic->matrix->of(DiviFeature::ThemeBuilderWrite)->problems
);
}
public function testAnUnknownVersionWithoutProblemsIsReleasedAutomatically(): void
{
$status = $this->resolver(self::UNKNOWN)->resolve();
self::assertSame(self::allBut('released'), self::states($status));
self::assertSame('auto', $status->source);
self::assertSame('auto', $status->matrix->of(DiviFeature::PresetWrite)->source);
self::assertTrue($status->writable());
}
/** Review focus 3: the automatic release needs every signature unchanged. */
public function testAChangedSignatureMakesItsFeatureIncompatibleUnderAutomaticRelease(): void
{
$status = $this->resolver(self::UNKNOWN, [
self::problem(Problem::SIGNATURE, [DiviFeature::PresetWrite], 'Designsystem: x() – Signatur geändert'),
])->resolve();
self::assertSame(self::allBut('released', ['preset-write' => 'incompatible']), self::states($status));
self::assertSame(
['Designsystem: x() – Signatur geändert'],
$status->matrix->of(DiviFeature::PresetWrite)->problems
);
self::assertFalse($status->writable());
}
public function testAMissingReferenceMakesItsFeatureUntestedUnderAutomaticRelease(): void
{
$status = $this->resolver(self::UNKNOWN, [
self::problem(Problem::REFERENCE_MISSING, [DiviFeature::Roles], 'Seiten: r() – Referenz fehlt'),
self::problem(Problem::REFERENCE_MISSING, [DiviFeature::PageRead]),
self::problem(Problem::SIGNATURE, [DiviFeature::PageRead]),
])->resolve();
self::assertSame(
self::allBut('released', ['roles' => 'untested', 'page-read' => 'incompatible']),
self::states($status)
);
self::assertSame(
['Seiten: r() – Referenz fehlt'],
$status->matrix->of(DiviFeature::Roles)->problems
);
}
/** Review focus 1: unknown version with the automatic release switched off. */
public function testWithoutAutomaticReleaseAnUnknownVersionIsUntestedUnlessSomethingHardFailed(): void
{
Filters\expectApplied(ReleaseResolver::AUTO_RELEASE_FILTER)->once()->with(true)->andReturn(false);
$status = $this->resolver(self::UNKNOWN, [
self::problem(Problem::SIGNATURE, [DiviFeature::PresetWrite]),
self::problem(Problem::CALL, [DiviFeature::Catalog]),
])->resolve();
self::assertSame(self::allBut('untested', ['catalog' => 'incompatible']), self::states($status));
self::assertNull($status->matrix->of(DiviFeature::PageRead)->source);
}
public function testOnlyALiteralTrueSwitchesTheAutomaticReleaseOn(): void
{
Filters\expectApplied(ReleaseResolver::AUTO_RELEASE_FILTER)->once()->andReturn(1);
self::assertSame(self::allBut('untested'), self::states($this->resolver(self::UNKNOWN)->resolve()));
}
public function testADatabaseReleaseReleasesEveryFeatureWithoutHardProblems(): void
{
$entry = ['user_id' => 1, 'released_at' => 'd'];
$status = $this->resolver(self::UNKNOWN, [
self::problem(Problem::SIGNATURE, [DiviFeature::PageWrite]),
self::problem(Problem::REFERENCE_MISSING, [DiviFeature::Catalog]),
], [self::problem(Problem::MIRROR, [DiviFeature::LibraryWrite])], $entry)->resolve();
self::assertSame(self::allBut('released', ['library-write' => 'incompatible']), self::states($status));
self::assertSame('database', $status->matrix->of(DiviFeature::PageWrite)->source);
self::assertSame($entry, $status->release);
}
public function testWithoutDiviNothingIsReleasedAutomatically(): void
{
$status = $this->resolver('')->resolve();
self::assertSame(ReleaseState::Untested, $status->state);
self::assertSame(self::allBut('untested'), self::states($status));
}
public function testCodeProblemsRunOnEveryResolveMirrorProblemsOncePerStateKey(): void
{
$mirrors = [self::problem(Problem::MIRROR, [DiviFeature::ThemeBuilderWrite], 'Theme Builder: Nachbau von y()')];
$first = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
$second = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
self::assertSame(1, $calls, 'the second resolve reads the transient');
self::assertSame(ReleaseState::Incompatible, $second->state);
self::assertSame(2, $this->codeRuns, 'code and signature checks are live');
self::assertSame(1, $this->mirrorRuns, 'the second resolve reads the transient');
self::assertSame(self::states($first), self::states($second));
self::assertSame($first->problems, $second->problems);
self::assertSame('key-5.2.1', $this->transients[ReleaseResolver::CACHE]['key']);
self::assertSame(
['format' => 2, 'key' => 'key-5.2.1', 'problems' => [
['label' => 'Theme Builder: Nachbau von y()', 'kind' => 'mirror', 'features' => ['tb-write']],
]],
$this->transients[ReleaseResolver::CACHE]
);
$this->resolver('5.2.2', mirrors: $mirrors)->resolve();
self::assertSame(2, $calls, 'another state key checks again');
self::assertSame(2, $this->mirrorRuns, 'another state key checks again');
}
public function testWithoutDiviTheVersionIsEmptyAndUntested(): void
/** Review focus 1: an old or broken transient is never trusted. */
public function testAnOldOrBrokenCacheEntryIsAMiss(): void
{
self::assertSame(ReleaseState::Untested, $this->resolver('')->resolve()->state);
$mirrors = [self::problem(Problem::MIRROR, [DiviFeature::PageWrite])];
$entries = [
'format 1' => ['key' => 'key-5.2.1', 'problems' => []],
'other format' => ['format' => 3, 'key' => 'key-5.2.1', 'problems' => []],
'unknown feature' => ['format' => 2, 'key' => 'key-5.2.1', 'problems' => [
['label' => 'x', 'kind' => 'mirror', 'features' => ['page-delete']],
]],
'no list' => ['format' => 2, 'key' => 'key-5.2.1', 'problems' => 'none'],
'garbage' => 'x',
];
foreach ($entries as $case => $entry) {
$this->transients[ReleaseResolver::CACHE] = $entry;
$runs = $this->mirrorRuns;
$status = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
self::assertSame($runs + 1, $this->mirrorRuns, $case);
self::assertFalse($status->matrix->of(DiviFeature::PageWrite)->usable(), $case);
}
}
public function testTheCacheHoldsProblemsSoAReleaseCountsAtOnce(): void
{
Filters\expectApplied(ReleaseResolver::AUTO_RELEASE_FILTER)->andReturn(false);
$before = $this->resolver(self::UNKNOWN)->resolve();
$after = $this->resolver(self::UNKNOWN, entry: ['user_id' => 1, 'released_at' => 'd'])->resolve();
self::assertSame(ReleaseState::Untested, $before->state);
self::assertSame(ReleaseState::Released, $after->state);
self::assertSame(1, $this->mirrorRuns);
}
}
@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
@@ -83,4 +86,50 @@ final class ReleaseStatusTest extends TestCase
$status->toArray()
);
}
public function testWithoutMatrixEveryFeatureIsInTheGivenState(): void
{
$untested = new ReleaseStatus(ReleaseState::Untested, '5.3.0');
$broken = new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']);
$released = ReleaseStatus::released('5.2.1', ReleaseStatus::SOURCE_DATABASE);
foreach (DiviFeature::cases() as $feature) {
self::assertSame(ReleaseState::Untested, $untested->matrix->of($feature)->state);
self::assertSame(['x'], $broken->matrix->of($feature)->problems);
self::assertSame('database', $released->matrix->of($feature)->source);
}
}
public function testOfTakesStateSourceAndProblemsFromTheMatrix(): void
{
$status = ReleaseStatus::of(
'5.99.0',
ReleaseMatrices::matrix([DiviFeature::Catalog], ReleaseState::Incompatible),
['user_id' => 1]
);
self::assertSame(ReleaseState::Incompatible, $status->state);
self::assertNull($status->source);
self::assertSame(['Seiten: et_x() – Signatur geändert'], $status->problems);
self::assertSame(['user_id' => 1], $status->release);
self::assertSame('auto', ReleaseStatus::of('5.99.0', CompatibilityMatrix::allReleased('auto'))->source);
}
public function testWritableAsksOnlyTheWritingFeatures(): void
{
$readLocked = ReleaseMatrices::status([DiviFeature::Catalog, DiviFeature::PageRead]);
self::assertSame(ReleaseState::Untested, $readLocked->state);
self::assertTrue($readLocked->writable());
foreach (CompatibilityMatrix::writeFeatures() as $feature) {
self::assertFalse(ReleaseMatrices::status([$feature])->writable(), $feature->value);
}
}
public function testAMatrixInAnotherStateThanTheStatusIsRefused(): void
{
$this->expectException(LogicException::class);
new ReleaseStatus(ReleaseState::Released, '5.2.1', 'code', [], null, CompatibilityMatrix::allIncompatible('x'));
}
}