refactor(compat): Freigabe-Bausteine generisch

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 12:51:03 +01:00
co-authored by Claude Opus 5.5
parent c85131603b
commit 7d6a97a54e
48 changed files with 1469 additions and 647 deletions
@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use PHPUnit\Framework\TestCase;
final class CheckReportTest extends TestCase
{
private static function report(array $steps): CheckReport
{
return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, '5.3.0', '0.6.0', 'key', $steps);
}
private static function green(): array
{
return array_map(
static fn (string $key): CheckStep => new CheckStep($key, []),
[CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
);
}
public function testGreenOnlyWithEveryStepGreenAndNoneSkipped(): void
{
self::assertTrue(self::report(self::green())->green());
self::assertFalse(self::report([])->green());
$red = self::green();
$red[2] = new CheckStep(CheckStep::CATALOG, ['divi/text: unbekanntes Modul']);
self::assertFalse(self::report($red)->green());
$skipped = self::green();
$skipped[4] = new CheckStep(CheckStep::WRITE, [], true);
self::assertFalse(self::report($skipped)->green());
}
public function testFreshOnlyForTheSameVersionsAndStateKey(): void
{
$report = self::report(self::green());
self::assertTrue($report->freshFor('5.3.0', '0.6.0', 'key'));
self::assertFalse($report->freshFor('5.3.1', '0.6.0', 'key'), 'Divi updated');
self::assertFalse($report->freshFor('5.3.0', '0.7.0', 'key'), 'plugin updated');
self::assertFalse($report->freshFor('5.3.0', '0.6.0', 'other'), 'a Divi file changed');
}
public function testRoundTripsThroughAnArray(): void
{
$steps = self::green();
$steps[1] = new CheckStep(CheckStep::MIRRORS, ['Theme Builder: Nachbau von x() – y']);
$report = self::report($steps);
self::assertEquals($report, CheckReport::fromArray($report->toArray()));
self::assertNull(CheckReport::fromArray('broken'));
self::assertNull(CheckReport::fromArray(['id' => 'r1']));
self::assertSame('Fingerabdrücke nachgebauter Divi-Funktionen', $report->step(CheckStep::MIRRORS)?->label());
}
}
@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class CompatibilityCheckTest extends TestCase
{
private function check(
array $code,
array $mirrors,
?WriteProbe $probe = null,
?\Closure $catalog = null
): CompatibilityCheck {
Functions\when('wp_generate_uuid4')->justReturn('uuid-1');
$manifests = Mockery::mock(ManifestCheck::class);
$manifests->allows('codeChecks')->andReturn($code);
$manifests->allows('registrationChecks')->andReturn([]);
$manifests->allows('mirrorChecks')->andReturn($mirrors);
$format = Mockery::mock(FormatCheck::class);
$format->allows('problems')->andReturn([]);
if ($probe === null) {
$probe = Mockery::mock(WriteProbe::class);
$probe->allows('run')->andReturn([]);
}
return new CompatibilityCheck(
$manifests,
$catalog ?? static fn (): array => [],
$format,
$probe,
static fn (): string => '5.3.0',
static fn (string $version): string => 'key-' . $version
);
}
public function testAGreenRunNamesVersionsAndStateKey(): void
{
$report = $this->check([], [])->run(7);
self::assertTrue($report->green());
self::assertSame(['uuid-1', 7, '5.3.0', Plugin::VERSION, 'key-5.3.0'], [
$report->id, $report->userId, $report->diviVersion, $report->pluginVersion, $report->stateKey,
]);
self::assertSame(
['manifest', 'mirrors', 'catalog', 'format', 'write'],
array_map(static fn (CheckStep $step): string => $step->key, $report->steps)
);
}
public function testNoWriteTestWhenDiviFunctionsAreMissing(): void
{
$probe = Mockery::mock(WriteProbe::class);
$probe->expects('run')->never();
$report = $this->check(['Seiten: et_x()' => static fn (): bool => false], [], $probe)->run(7);
self::assertSame(['Seiten: et_x()'], $report->step(CheckStep::MANIFEST)?->problems);
self::assertTrue($report->step(CheckStep::WRITE)?->skipped);
}
public function testChangedFingerprintsStillRunTheWriteTest(): void
{
$probe = Mockery::mock(WriteProbe::class);
$probe->expects('run')->once()->andReturn([]);
$report = $this->check([], ['TB: Nachbau von y()' => static fn (): bool => false], $probe)->run(7);
self::assertFalse($report->green());
self::assertTrue($report->step(CheckStep::WRITE)?->green());
}
public function testAThrowingStepIsARedStep(): void
{
$report = $this->check([], [], null, static function (): never {
throw new RuntimeException('Katalog weg');
})->run(7);
self::assertSame(['Prüfschritt abgebrochen: Katalog weg'], $report->step(CheckStep::CATALOG)?->problems);
}
}
@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use LIT\MCP\Tests\Unit\TestCase;
use Brain\Monkey\Functions;
use Closure;
use Mockery;
use RuntimeException;
final class DiviCheckStepsTest extends TestCase
{
public function testAStepCarriesItsLabel(): void
{
$step = DiviCheckSteps::step(DiviCheckSteps::CATALOG, ['x']);
self::assertSame('catalog', $step->key);
self::assertSame(['x'], $step->problems);
self::assertSame('Modulkatalog (Kurzform-Pfade)', $step->label());
self::assertTrue(DiviCheckSteps::step(DiviCheckSteps::WRITE, [], true)->skipped);
}
public function testTheLabelOfAnOldReportStepComesFromTheKey(): void
{
self::assertSame(
'Schreibtest auf einer Wegwerfseite',
DiviCheckSteps::label(new CheckStep(DiviCheckSteps::WRITE, []))
);
self::assertSame(
'Divi-Funktionen, Klassen und Registrierungen',
DiviCheckSteps::label(new CheckStep(CheckStep::MANIFEST, []))
);
self::assertSame('stored', DiviCheckSteps::label(new CheckStep('catalog', [], false, 'stored')));
self::assertSame('other', DiviCheckSteps::label(new CheckStep('other', [])));
}
/** @param array<string, Closure(): bool> $code */
private function check(array $code, array $mirrors, ?Closure $probe = null, ?Closure $catalog = null): CompatibilityCheck
{
Functions\when('wp_generate_uuid4')->justReturn('uuid-1');
$manifests = Mockery::mock(ManifestCheck::class);
$manifests->allows('codeChecks')->andReturn($code);
$manifests->allows('registrationChecks')->andReturn([]);
$manifests->allows('mirrorChecks')->andReturn($mirrors);
$format = Mockery::mock(FormatCheck::class);
$format->allows('problems')->andReturn([]);
$profile = new Profile(
id: 'divi',
label: 'Divi',
tier: Tier::ReleasePerVersion,
capabilities: DiviFeature::cases(),
manifests: [new Manifest('Seiten', mirrors: [new Mirror('m', 'f.php', 'fn', null, 'x')])],
version: static fn (): ?string => '5.3.0',
matrix: static fn () => throw new RuntimeException('unused'),
stateKey: static fn (string $version): string => 'key-' . $version,
extraSteps: DiviCheckSteps::extra(
$catalog ?? static fn (): array => [],
$format,
$probe ?? static fn (): array => []
)
);
return new CompatibilityCheck($profile, $manifests);
}
public function testTheDiviStepOrderIsUnchanged(): void
{
$report = $this->check([], [])->run(7);
self::assertTrue($report->green());
self::assertSame(
['manifest', 'mirrors', 'catalog', 'format', 'write'],
array_map(static fn (CheckStep $step): string => $step->key, $report->steps)
);
self::assertSame('Schreibtest auf einer Wegwerfseite', $report->step('write')?->label());
self::assertSame('Divi-Funktionen, Klassen und Registrierungen', $report->step('manifest')?->label());
self::assertSame('key-5.3.0', $report->stateKey);
}
public function testNoWriteTestWhenDiviFunctionsAreMissing(): void
{
$ran = false;
$probe = static function () use (&$ran): array {
$ran = true;
return [];
};
$report = $this->check(['Seiten: et_x()' => static fn (): bool => false], [], $probe)->run(7);
self::assertSame(['Seiten: et_x()'], $report->step(CheckStep::MANIFEST)?->problems);
self::assertTrue($report->step(DiviCheckSteps::WRITE)?->skipped);
self::assertFalse($ran);
}
public function testChangedFingerprintsStillRunTheWriteTest(): void
{
$report = $this->check([], ['TB: Nachbau von y()' => static fn (): bool => false])->run(7);
self::assertFalse($report->green());
self::assertTrue($report->step(DiviCheckSteps::WRITE)?->green());
}
public function testAThrowingStepIsARedStep(): void
{
$report = $this->check([], [], null, static function (): never {
throw new RuntimeException('Katalog weg');
})->run(7);
self::assertSame(['Prüfschritt abgebrochen (Details im PHP-Error-Log).'], $report->step(DiviCheckSteps::CATALOG)?->problems);
}
}
@@ -5,12 +5,15 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\DiviReleases;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
@@ -32,6 +35,9 @@ final class DiviProfileTest extends TestCase
self::assertSame('divi', $profile->id);
self::assertSame('Divi', $profile->label);
self::assertSame('divi_version', $profile->reportVersionKey, 'stored Divi data keeps its key');
self::assertSame(DiviProfile::ID, $profile->id);
self::assertSame(DiviProfile::REPORT_VERSION_KEY, $profile->reportVersionKey);
self::assertSame(Tier::ReleasePerVersion, $profile->tier);
self::assertSame(DiviFeature::cases(), $profile->capabilities);
self::assertEquals(array_values(DiviAdapters::manifests()), $profile->manifests);
@@ -45,14 +51,25 @@ final class DiviProfileTest extends TestCase
self::assertSame($status->matrix, (DiviProfile::profile()->matrix)());
}
public function testTheVersionComesFromTheDiviTemplateAndThereIsNoActiveCheck(): void
public function testTheVersionComesFromTheDiviTemplateAndActiveMeansDiviIsInstalled(): void
{
$this->theme('Divi', '5.3.0');
$profile = DiviProfile::profile();
self::assertNull($profile->active, 'Divi has its own component; DiviIntegration decides availability');
self::assertTrue(($profile->active)());
self::assertSame('5.3.0', ($profile->version)());
$this->theme('twentytwentyfive');
self::assertFalse(($profile->active)());
self::assertNull(($profile->version)());
}
public function testCarriesTestedVersionsStateKeyAndManifestCheck(): void
{
$this->theme('twentytwentyfive');
$profile = DiviProfile::profile();
self::assertSame(DiviReleases::TESTED, $profile->testedVersions);
self::assertSame(ReleaseResolver::stateKey('5.3.0'), ($profile->stateKey)('5.3.0'));
self::assertInstanceOf(ManifestCheck::class, ($profile->manifestCheck)());
}
}
@@ -12,7 +12,7 @@ use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Problem;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Tests\Unit\TestCase;
final class ReleaseStoreTest extends TestCase
{
/** @var array<string, mixed> */
private array $options = [];
protected function setUp(): void
{
parent::setUp();
$this->options = [];
Functions\when('get_option')->alias(
fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default
);
Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool {
$this->options[$name] = $value;
self::assertFalse($autoload, 'releases are read only by the gate – no autoload');
return true;
});
}
public function testAddsFindsAndWithdrawsPerDiviAndPluginVersion(): void
{
$store = new ReleaseStore();
$store->add('5.3.0', '0.6.0', 1, 'r1');
$store->add('5.3.0', '0.6.0', 2, 'r2');
self::assertCount(1, $store->entries(), 'one entry per version pair');
self::assertSame(2, $store->find('5.3.0', '0.6.0')['user_id'] ?? null);
self::assertNull($store->find('5.3.0', '0.7.0'), 'another plugin version needs its own check');
self::assertTrue($store->withdraw('5.3.0', '0.6.0'));
self::assertFalse($store->withdraw('5.3.0', '0.6.0'));
self::assertSame([], $store->entries());
}
public function testIgnoresBrokenRows(): void
{
$this->options[ReleaseStore::OPTION] = ['x', ['divi_version' => '5.3.0'], [
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3,
'released_at' => 'd', 'report_id' => 'r',
]];
self::assertSame(['5.2.2'], array_column((new ReleaseStore())->entries(), 'divi_version'));
}
}
@@ -97,9 +97,18 @@ final class WriteProbeTest extends TestCase
throw new RuntimeException('Composer kaputt');
});
$problems = (new WriteProbe($composer, $this->renderer(''), '/nonexistent-divi'))->run();
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$problems = (new WriteProbe($composer, $this->renderer(''), '/nonexistent-divi'))->run();
$logged = (string) file_get_contents($log);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
self::assertSame(['Schreibtest abgebrochen: Composer kaputt'], $problems);
self::assertSame(['Schreibtest abgebrochen (Details im PHP-Error-Log).'], $problems);
self::assertStringContainsString('Composer kaputt', $logged);
self::assertSame([41], $this->deleted);
$this->expectException(\LIT\MCP\Integrations\Divi\Release\DiviLocked::class);
ReleaseGate::assertFeature(DiviFeature::PageWrite, 41);