diff --git a/src/Admin/DiviTab.php b/src/Admin/DiviTab.php index 3fdff5d..f016240 100644 --- a/src/Admin/DiviTab.php +++ b/src/Admin/DiviTab.php @@ -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'] ?? '')); diff --git a/src/Integrations/Divi/Release/CompatibilityMatrix.php b/src/Integrations/Divi/Release/CompatibilityMatrix.php new file mode 100644 index 0000000..424d658 --- /dev/null +++ b/src/Integrations/Divi/Release/CompatibilityMatrix.php @@ -0,0 +1,136 @@ + keyed by DiviFeature value, in DiviFeature::cases() order */ + private array $features = []; + + /** @param array $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 $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 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 $features */ + public function usable(array $features): bool + { + foreach ($features as $feature) { + if (! $this->of($feature)->usable()) { + return false; + } + } + + return true; + } + + /** @return list */ + 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 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, source: ?string}> */ + public function toArray(): array + { + return array_values(array_map(static fn (FeatureStatus $status): array => $status->toArray(), $this->features)); + } +} diff --git a/src/Integrations/Divi/Release/FeatureStatus.php b/src/Integrations/Divi/Release/FeatureStatus.php new file mode 100644 index 0000000..29b3775 --- /dev/null +++ b/src/Integrations/Divi/Release/FeatureStatus.php @@ -0,0 +1,49 @@ + $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} 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, source: ?string} + * For lit-site-status and the admin tab. + */ + public function toArray(): array + { + return $this->summary() + ['source' => $this->source]; + } +} diff --git a/src/Integrations/Divi/Release/ManifestCheck.php b/src/Integrations/Divi/Release/ManifestCheck.php index f765077..a91fe1f 100644 --- a/src/Integrations/Divi/Release/ManifestCheck.php +++ b/src/Integrations/Divi/Release/ManifestCheck.php @@ -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|null $manifests null = DiviAdapters::manifests(). - * @param array|null $expected Mirror ID => sha256; null = DiviReference::expected(). + * @param array|null $expected Mirror ID or "sig:" => 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 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 + */ + 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 + */ + 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 */ + 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(); diff --git a/src/Integrations/Divi/Release/Problem.php b/src/Integrations/Divi/Release/Problem.php new file mode 100644 index 0000000..438b831 --- /dev/null +++ b/src/Integrations/Divi/Release/Problem.php @@ -0,0 +1,90 @@ + $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} 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']); + } +} diff --git a/src/Integrations/Divi/Release/ReleaseGate.php b/src/Integrations/Divi/Release/ReleaseGate.php index 3d21383..102a3c4 100644 --- a/src/Integrations/Divi/Release/ReleaseGate.php +++ b/src/Integrations/Divi/Release/ReleaseGate.php @@ -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; diff --git a/src/Integrations/Divi/Release/ReleaseResolver.php b/src/Integrations/Divi/Release/ReleaseResolver.php index 02b82f0..30eff1d 100644 --- a/src/Integrations/Divi/Release/ReleaseResolver.php +++ b/src/Integrations/Divi/Release/ReleaseResolver.php @@ -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 */ + /** 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 $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 $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 $problems + * @return list + */ + private static function labels(array $problems): array + { + return array_values(array_map(static fn (Problem $problem): string => $problem->label, $problems)); + } + + /** @return list */ 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|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; } diff --git a/src/Integrations/Divi/Release/ReleaseStatus.php b/src/Integrations/Divi/Release/ReleaseStatus.php index 0cb6db0..54d7780 100644 --- a/src/Integrations/Divi/Release/ReleaseStatus.php +++ b/src/Integrations/Divi/Release/ReleaseStatus.php @@ -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 $problems Failed code and mirror checks (ManifestCheck labels). * @param array|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|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 diff --git a/tests/Support/ReleaseMatrices.php b/tests/Support/ReleaseMatrices.php new file mode 100644 index 0000000..9225040 --- /dev/null +++ b/tests/Support/ReleaseMatrices.php @@ -0,0 +1,41 @@ + $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 $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)); + } +} diff --git a/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php index d5e0ba4..22210fc 100644 --- a/tests/Unit/Admin/DiviTabTest.php +++ b/tests/Unit/Admin/DiviTabTest.php @@ -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', $html); + self::assertStringNotContainsString('Freigabe zurückziehen', $html); + } + public function testADatabaseReleaseCanBeWithdrawn(): void { $status = new ReleaseStatus(ReleaseState::Released, '5.3.0', ReleaseStatus::SOURCE_DATABASE, [], [ diff --git a/tests/Unit/Integrations/Divi/Release/CompatibilityMatrixTest.php b/tests/Unit/Integrations/Divi/Release/CompatibilityMatrixTest.php new file mode 100644 index 0000000..75703a2 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/CompatibilityMatrixTest.php @@ -0,0 +1,137 @@ + 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}> $locked + * @return array + */ + 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}> $locked */ + private static function matrix(array $locked): CompatibilityMatrix + { + return new CompatibilityMatrix(self::features($locked)); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/FeatureStatusTest.php b/tests/Unit/Integrations/Divi/Release/FeatureStatusTest.php new file mode 100644 index 0000000..a2a356b --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/FeatureStatusTest.php @@ -0,0 +1,32 @@ +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()); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php b/tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php index dbcea6c..046aa85 100644 --- a/tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php +++ b/tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php @@ -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 $problems + * @return list}> + */ + 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') + ); + } } diff --git a/tests/Unit/Integrations/Divi/Release/ProblemTest.php b/tests/Unit/Integrations/Divi/Release/ProblemTest.php new file mode 100644 index 0000000..78020ce --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/ProblemTest.php @@ -0,0 +1,62 @@ +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); + } + } +} diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php index 7cc7a42..8225b3d 100644 --- a/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php +++ b/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php @@ -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); diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php index 9df4bcc..59ee1f3 100644 --- a/tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php +++ b/tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php @@ -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 */ 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 $code - * @param array $mirrors + * @param list $code + * @param list $mirrors + * @param array|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 $features */ + private static function problem(string $kind, array $features, string $label = 'Seiten: et_x()'): Problem + { + return new Problem($label, $features, $kind); + } + + /** @return array 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 $except Feature value => state. + * @return array + */ + 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); } } diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php index 0a5c83e..6ac7efb 100644 --- a/tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php +++ b/tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php @@ -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')); + } }