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
+158 -24
View File
@@ -6,6 +6,8 @@ namespace LIT\MCP\Tests\Unit\Compatibility;
use Closure;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\Manifest;
@@ -13,6 +15,7 @@ use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
@@ -36,26 +39,26 @@ final class CompatibilityTest extends TestCase
Tier $tier = Tier::LockOnFailure
): Profile {
return new Profile(
'fake',
'Fake Plugin',
$tier,
self::CAPS,
$broken ? [self::brokenManifest()] : [],
$active,
$version,
$minVersion
id: 'fake',
label: 'Fake Plugin',
tier: $tier,
capabilities: self::CAPS,
manifests: $broken ? [self::brokenManifest()] : [],
active: $active,
version: $version,
minVersion: $minVersion
);
}
public function testReportOnlyNeverLocksNotEvenWhenInactiveOrBroken(): void
{
$profile = new Profile(
'fake',
'Fake',
Tier::ReportOnly,
self::CAPS,
[self::brokenManifest()],
static fn (): bool => false,
id: 'fake',
label: 'Fake',
tier: Tier::ReportOnly,
capabilities: self::CAPS,
manifests: [self::brokenManifest()],
active: static fn (): bool => false,
minVersion: '9'
);
$compat = new Compatibility([$profile]);
@@ -155,10 +158,10 @@ final class CompatibilityTest extends TestCase
public function testReleasePerVersionTakesItsMatrixFromTheClosure(): void
{
$profile = new Profile(
'fake',
'Fake',
Tier::ReleasePerVersion,
self::CAPS,
id: 'fake',
label: 'Fake',
tier: Tier::ReleasePerVersion,
capabilities: self::CAPS,
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(self::CAPS, 'gesperrt')
);
$compat = new Compatibility([$profile]);
@@ -173,10 +176,10 @@ final class CompatibilityTest extends TestCase
$matrices = 0;
$lock = self::lockProfile();
$release = new Profile(
'release',
'Release',
Tier::ReleasePerVersion,
[FakeCapability::Other],
id: 'release',
label: 'Release',
tier: Tier::ReleasePerVersion,
capabilities: [FakeCapability::Other],
matrix: static function () use (&$matrices): CompatibilityMatrix {
$matrices++;
@@ -203,7 +206,7 @@ final class CompatibilityTest extends TestCase
public function testLookups(): void
{
$lock = self::lockProfile();
$other = new Profile('other', 'Other', Tier::ReportOnly, [FakeCapability::Other]);
$other = new Profile(id: 'other', label: 'Other', tier: Tier::ReportOnly, capabilities: [FakeCapability::Other]);
$compat = new Compatibility([$lock, $other]);
self::assertSame([$lock, $other], $compat->profiles());
@@ -222,7 +225,7 @@ final class CompatibilityTest extends TestCase
new Compatibility([
self::lockProfile(),
new Profile('other', 'Other', Tier::ReportOnly, [FakeCapability::Read]),
new Profile(id: 'other', label: 'Other', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read]),
]);
}
@@ -230,4 +233,135 @@ final class CompatibilityTest extends TestCase
{
self::assertFalse((new Compatibility([]))->usable(FakeCapability::Read));
}
private static function report(bool $green, string $version = '2.1.0', string $stateKey = 'k1'): CheckReport
{
return new CheckReport('r1', 'd', 1, $version, Plugin::VERSION, $stateKey, [
new CheckStep('probe', $green ? [] : ['Schreibprobe fehlgeschlagen']),
]);
}
private static function reported(?CheckReport $report, bool $withStateKey = true): Compatibility
{
$profile = new Profile(
id: 'fake',
label: 'Fake Plugin',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
version: static fn (): ?string => '2.1.0',
stateKey: $withStateKey ? static fn (string $version): string => 'k1' : null
);
return new Compatibility([$profile], report: static fn (Profile $p): ?CheckReport => $report);
}
public function testARedReportOfTheCurrentStateLocksEveryCapability(): void
{
$compat = self::reported(self::report(false));
self::assertSame(ReleaseState::Incompatible, $compat->matrix($compat->profile('fake'))->state());
self::assertNotNull($compat->lockOf(self::CAPS));
$reason = implode(' ', $compat->problems($compat->profile('fake')));
self::assertStringContainsString('(probe)', $reason);
self::assertStringNotContainsString('Schreibprobe fehlgeschlagen', $reason, 'step texts may hold exception text');
}
public function testAReportWithoutStepsIsNoResultAndDoesNotLock(): void
{
$empty = new CheckReport('r1', 'd', 1, '2.1.0', Plugin::VERSION, 'k1', []);
self::assertNull(self::reported($empty)->lockOf(self::CAPS));
}
public function testActiveAndVersionRunTheirClosuresOncePerInstance(): void
{
$calls = 0;
$profile = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
active: static function () use (&$calls): bool {
$calls++;
return true;
},
version: static function () use (&$calls): string {
$calls++;
return '1.0';
}
);
$compat = new Compatibility([$profile]);
foreach ([1, 2, 3] as $ignored) {
self::assertTrue($compat->active($profile));
self::assertSame('1.0', $compat->version($profile));
}
self::assertSame(2, $calls);
}
public function testAThrowingActiveClosureIsLoggedOnlyOnce(): void
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
$profile = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
active: static fn (): bool => throw new RuntimeException('einmal')
);
$compat = new Compatibility([$profile]);
try {
$compat->active($profile);
$compat->active($profile);
$compat->lockOf(self::CAPS);
$logged = (string) file_get_contents($log);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
self::assertSame(1, substr_count($logged, 'einmal'));
}
public function testAnOtherStateOrAGreenReportOrNoneDoesNotLock(): void
{
foreach ([self::report(false, stateKey: 'old'), self::report(false, version: '2.0.0'), self::report(true), null] as $report) {
self::assertNull(self::reported($report)->lockOf(self::CAPS));
}
}
public function testWithoutAStateKeyTheVersionDecides(): void
{
self::assertNotNull(self::reported(self::report(false, stateKey: ''), false)->lockOf(self::CAPS));
self::assertNull(self::reported(self::report(false, version: '9', stateKey: ''), false)->lockOf(self::CAPS));
}
public function testACaughtThrowableIsLogged(): void
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
$profile = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
active: static fn (): bool => throw new RuntimeException('geheim-xyz')
);
try {
self::assertFalse((new Compatibility([$profile]))->active($profile));
$logged = (string) file_get_contents($log);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
self::assertStringContainsString('compat fake failed', $logged);
self::assertStringContainsString('geheim-xyz', $logged);
}
}