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>
368 lines
13 KiB
PHP
368 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
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;
|
|
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;
|
|
use RuntimeException;
|
|
|
|
final class CompatibilityTest extends TestCase
|
|
{
|
|
private const CAPS = [FakeCapability::Read, FakeCapability::Write];
|
|
|
|
/** A manifest whose only call is missing and belongs to fake-write: a hard problem for that capability. */
|
|
private static function brokenManifest(): Manifest
|
|
{
|
|
return new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])]);
|
|
}
|
|
|
|
private static function lockProfile(
|
|
bool $broken = false,
|
|
?Closure $active = null,
|
|
?Closure $version = null,
|
|
?string $minVersion = null,
|
|
Tier $tier = Tier::LockOnFailure
|
|
): Profile {
|
|
return new Profile(
|
|
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(
|
|
id: 'fake',
|
|
label: 'Fake',
|
|
tier: Tier::ReportOnly,
|
|
capabilities: self::CAPS,
|
|
manifests: [self::brokenManifest()],
|
|
active: static fn (): bool => false,
|
|
minVersion: '9'
|
|
);
|
|
$compat = new Compatibility([$profile]);
|
|
|
|
self::assertSame(ReleaseState::Released, $compat->matrix($profile)->state());
|
|
self::assertNull($compat->lockOf(self::CAPS));
|
|
self::assertFalse($compat->active($profile));
|
|
self::assertSame(['Fake: lit_mcp_missing_function()'], $compat->problems($profile));
|
|
}
|
|
|
|
public function testLockOnFailureLocksOnlyWhatAHardProblemConcerns(): void
|
|
{
|
|
$compat = new Compatibility([self::lockProfile(broken: true)]);
|
|
|
|
self::assertTrue($compat->usable(FakeCapability::Read));
|
|
self::assertFalse($compat->usable(FakeCapability::Write));
|
|
self::assertSame([FakeCapability::Write], array_map(
|
|
static fn ($status) => $status->feature,
|
|
$compat->lockOf(self::CAPS)?->locked ?? []
|
|
));
|
|
}
|
|
|
|
public function testLockOnFailureWithoutProblemsIsFree(): void
|
|
{
|
|
$compat = new Compatibility([self::lockProfile()]);
|
|
|
|
self::assertNull($compat->lockOf(self::CAPS));
|
|
self::assertSame([], $compat->problems($compat->profile('fake')));
|
|
}
|
|
|
|
public function testAnInactivePluginLocksEverythingWithLockOnFailure(): void
|
|
{
|
|
$profile = self::lockProfile(active: static fn (): bool => false);
|
|
$compat = new Compatibility([$profile]);
|
|
|
|
self::assertFalse($compat->active($profile));
|
|
self::assertFalse($compat->usable(FakeCapability::Read));
|
|
self::assertSame(['Fake Plugin ist nicht installiert oder nicht aktiv.'], $compat->problems($profile));
|
|
}
|
|
|
|
public function testAVersionBelowTheMinimumLocksEverything(): void
|
|
{
|
|
$profile = self::lockProfile(version: static fn (): string => '2.7.9', minVersion: '2.8');
|
|
$compat = new Compatibility([$profile]);
|
|
|
|
self::assertSame('2.7.9', $compat->version($profile));
|
|
self::assertFalse($compat->usable(FakeCapability::Read));
|
|
self::assertFalse($compat->usable(FakeCapability::Write));
|
|
self::assertSame(
|
|
['Fake Plugin 2.7.9 ist älter als die Mindestversion 2.8.'],
|
|
$compat->problems($profile)
|
|
);
|
|
}
|
|
|
|
public function testTheMinimumVersionItselfIsFine(): void
|
|
{
|
|
$profile = self::lockProfile(version: static fn (): string => '2.8', minVersion: '2.8');
|
|
|
|
self::assertTrue((new Compatibility([$profile]))->usable(FakeCapability::Read));
|
|
}
|
|
|
|
public function testAnUnknownVersionWithAMinimumLocksFailClosed(): void
|
|
{
|
|
$profile = self::lockProfile(version: static fn (): ?string => null, minVersion: '2.8');
|
|
|
|
self::assertFalse((new Compatibility([$profile]))->usable(FakeCapability::Read));
|
|
}
|
|
|
|
public function testAThrowingActiveOrVersionClosureCountsAsInactiveAndUnknown(): void
|
|
{
|
|
$boom = static function (): never {
|
|
throw new RuntimeException('boom');
|
|
};
|
|
$profile = self::lockProfile(active: $boom, version: $boom);
|
|
$compat = new Compatibility([$profile]);
|
|
|
|
self::assertFalse($compat->active($profile));
|
|
self::assertNull($compat->version($profile));
|
|
self::assertFalse($compat->usable(FakeCapability::Read));
|
|
}
|
|
|
|
public function testAThrowingCheckLocksEverythingFailClosed(): void
|
|
{
|
|
$profile = self::lockProfile();
|
|
$compat = new Compatibility([$profile], static function (): never {
|
|
throw new RuntimeException('boom');
|
|
});
|
|
|
|
self::assertFalse($compat->usable(FakeCapability::Read));
|
|
self::assertFalse($compat->usable(FakeCapability::Write));
|
|
self::assertSame(
|
|
['Die Kompatibilitätsprüfung von Fake Plugin ist fehlgeschlagen.'],
|
|
$compat->problems($profile)
|
|
);
|
|
}
|
|
|
|
public function testReleasePerVersionTakesItsMatrixFromTheClosure(): void
|
|
{
|
|
$profile = new Profile(
|
|
id: 'fake',
|
|
label: 'Fake',
|
|
tier: Tier::ReleasePerVersion,
|
|
capabilities: self::CAPS,
|
|
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(self::CAPS, 'gesperrt')
|
|
);
|
|
$compat = new Compatibility([$profile]);
|
|
|
|
self::assertFalse($compat->usable(FakeCapability::Read));
|
|
self::assertSame(['gesperrt'], $compat->problems($profile));
|
|
}
|
|
|
|
public function testMatrixAndCheckAreComputedOncePerProfile(): void
|
|
{
|
|
$checks = 0;
|
|
$matrices = 0;
|
|
$lock = self::lockProfile();
|
|
$release = new Profile(
|
|
id: 'release',
|
|
label: 'Release',
|
|
tier: Tier::ReleasePerVersion,
|
|
capabilities: [FakeCapability::Other],
|
|
matrix: static function () use (&$matrices): CompatibilityMatrix {
|
|
$matrices++;
|
|
|
|
return CompatibilityMatrix::allReleased([FakeCapability::Other]);
|
|
}
|
|
);
|
|
$count = static function (Profile $profile) use (&$checks): ManifestCheck {
|
|
$checks++;
|
|
|
|
return new ManifestCheck($profile->manifests, signatures: false);
|
|
};
|
|
$compat = new Compatibility([$lock, $release], $count);
|
|
|
|
foreach ([1, 2, 3] as $ignored) {
|
|
$compat->usable(FakeCapability::Read);
|
|
$compat->usable(FakeCapability::Other);
|
|
$compat->problems($lock);
|
|
}
|
|
|
|
self::assertSame(1, $checks);
|
|
self::assertSame(1, $matrices);
|
|
}
|
|
|
|
public function testLookups(): void
|
|
{
|
|
$lock = self::lockProfile();
|
|
$other = new Profile(id: 'other', label: 'Other', tier: Tier::ReportOnly, capabilities: [FakeCapability::Other]);
|
|
$compat = new Compatibility([$lock, $other]);
|
|
|
|
self::assertSame([$lock, $other], $compat->profiles());
|
|
self::assertSame($other, $compat->profile('other'));
|
|
self::assertNull($compat->profile('nope'));
|
|
self::assertSame(FakeCapability::Other, $compat->capability('fake-other'));
|
|
self::assertNull($compat->capability('fake-nope'));
|
|
self::assertSame($other, $compat->owner(FakeCapability::Other));
|
|
self::assertSame($lock, $compat->owner(FakeCapability::Read));
|
|
}
|
|
|
|
public function testAnIdTwoProfilesClaimIsAProgrammingError(): void
|
|
{
|
|
$this->expectException(LogicException::class);
|
|
$this->expectExceptionMessage('fake-read');
|
|
|
|
new Compatibility([
|
|
self::lockProfile(),
|
|
new Profile(id: 'other', label: 'Other', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read]),
|
|
]);
|
|
}
|
|
|
|
public function testACapabilityNoProfileOwnsIsNotUsable(): void
|
|
{
|
|
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);
|
|
}
|
|
}
|