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:
co-authored by
Claude Opus 5.5
parent
c85131603b
commit
7d6a97a54e
@@ -40,17 +40,17 @@ final class CapabilityGateTest extends TestCase
|
||||
private function gate(?FakeChangeLog $log = null, array $delegated = []): CapabilityGate
|
||||
{
|
||||
$lock = new Profile(
|
||||
'fake',
|
||||
'Fake Plugin',
|
||||
Tier::LockOnFailure,
|
||||
[FakeCapability::Read, FakeCapability::Write],
|
||||
[new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])])]
|
||||
id: 'fake',
|
||||
label: 'Fake Plugin',
|
||||
tier: Tier::LockOnFailure,
|
||||
capabilities: [FakeCapability::Read, FakeCapability::Write],
|
||||
manifests: [new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])])]
|
||||
);
|
||||
$release = new Profile(
|
||||
'release',
|
||||
'Release',
|
||||
Tier::ReleasePerVersion,
|
||||
[FakeCapability::Other],
|
||||
id: 'release',
|
||||
label: 'Release',
|
||||
tier: Tier::ReleasePerVersion,
|
||||
capabilities: [FakeCapability::Other],
|
||||
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(
|
||||
[FakeCapability::Other],
|
||||
'would lock'
|
||||
|
||||
@@ -18,7 +18,7 @@ final class CapabilityLockedTest extends TestCase
|
||||
{
|
||||
public function testTheErrorNamesPluginCapabilitiesAndProblems(): void
|
||||
{
|
||||
$profile = new Profile('fake', 'Fake Plugin', Tier::LockOnFailure, [FakeCapability::Write]);
|
||||
$profile = new Profile(id: 'fake', label: 'Fake Plugin', tier: Tier::LockOnFailure, capabilities: [FakeCapability::Write]);
|
||||
$locked = CapabilityLocked::of(
|
||||
[new FeatureStatus(FakeCapability::Write, ReleaseState::Incompatible, ['Fake: x() fehlt'])],
|
||||
[$profile]
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Compatibility;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Compatibility\CheckReport;
|
||||
use LIT\MCP\Compatibility\CheckReportStore;
|
||||
use LIT\MCP\Compatibility\CheckStep;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\ReleaseStore;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Tests\Support\FakeCapability;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class CheckReportTest 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, 'the report is read in the admin only – no autoload');
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param list<CheckStep> $steps */
|
||||
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);
|
||||
}
|
||||
|
||||
/** @return list<CheckStep> */
|
||||
private static function green(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (string $key): CheckStep => new CheckStep($key, []),
|
||||
[CheckStep::MANIFEST, CheckStep::MIRRORS, 'catalog']
|
||||
);
|
||||
}
|
||||
|
||||
/** What the Divi report looked like before the generic version: no label per step, key divi_version. */
|
||||
private static function oldDiviReport(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'r1',
|
||||
'created_at' => '2026-10-01T08:00:00+00:00',
|
||||
'user_id' => 1,
|
||||
'divi_version' => '5.3.0',
|
||||
'plugin_version' => '0.6.0',
|
||||
'state_key' => 'key',
|
||||
'steps' => [
|
||||
['key' => 'manifest', 'problems' => [], 'skipped' => false],
|
||||
['key' => 'mirrors', 'problems' => ['Theme Builder: Nachbau von x() – y'], 'skipped' => false],
|
||||
['key' => 'write', 'problems' => [], 'skipped' => true],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testGreenOnlyWithEveryStepGreenAndNoneSkipped(): void
|
||||
{
|
||||
self::assertTrue(self::report(self::green())->green());
|
||||
self::assertFalse(self::report([])->green());
|
||||
|
||||
$red = self::green();
|
||||
$red[2] = new CheckStep('catalog', ['divi/text: unbekanntes Modul']);
|
||||
self::assertFalse(self::report($red)->green());
|
||||
|
||||
$skipped = self::green();
|
||||
$skipped[2] = new CheckStep('catalog', [], 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'), 'plugin under test updated');
|
||||
self::assertFalse($report->freshFor('5.3.0', '0.7.0', 'key'), 'this plugin updated');
|
||||
self::assertFalse($report->freshFor('5.3.0', '0.6.0', 'other'), 'a source file changed');
|
||||
}
|
||||
|
||||
public function testRoundTripsThroughAnArrayWithTheLabel(): void
|
||||
{
|
||||
$steps = self::green();
|
||||
$steps[1] = new CheckStep(CheckStep::MIRRORS, ['x'], false, 'Fingerabdrücke');
|
||||
$report = self::report($steps);
|
||||
|
||||
self::assertEquals($report, CheckReport::fromArray($report->toArray()));
|
||||
self::assertSame('Fingerabdrücke', $report->step(CheckStep::MIRRORS)?->label());
|
||||
self::assertSame('Fingerabdrücke', $report->toArray()['steps'][1]['label']);
|
||||
self::assertNull(CheckReport::fromArray('broken'));
|
||||
self::assertNull(CheckReport::fromArray(['id' => 'r1']));
|
||||
}
|
||||
|
||||
public function testAStepWithoutLabelShowsItsKeyAndIsWrittenWithoutOne(): void
|
||||
{
|
||||
$step = new CheckStep('catalog', []);
|
||||
|
||||
self::assertSame('catalog', $step->label());
|
||||
self::assertArrayNotHasKey('label', $step->toArray());
|
||||
}
|
||||
|
||||
public function testTheGenericStepsHaveOneLabelSourceWithTheProfileName(): void
|
||||
{
|
||||
self::assertSame('Pods-Funktionen, Klassen und Registrierungen', CheckStep::genericLabel(CheckStep::MANIFEST, 'Pods'));
|
||||
self::assertSame('Fingerabdrücke nachgebauter Pods-Funktionen', CheckStep::genericLabel(CheckStep::MIRRORS, 'Pods'));
|
||||
self::assertNull(CheckStep::genericLabel('catalog', 'Pods'));
|
||||
}
|
||||
|
||||
public function testAReportWithoutStepsIsNotGreen(): void
|
||||
{
|
||||
self::assertFalse(self::report([])->green());
|
||||
}
|
||||
|
||||
public function testAnOldDiviReportIsReadAndWrittenUnchanged(): void
|
||||
{
|
||||
$old = self::oldDiviReport();
|
||||
|
||||
$report = CheckReport::fromArray($old, 'divi_version');
|
||||
|
||||
self::assertSame('5.3.0', $report?->version);
|
||||
self::assertSame('manifest', $report?->steps[0]->label(), 'no stored label: the raw key');
|
||||
self::assertSame($old, $report?->toArray('divi_version'));
|
||||
self::assertNull(CheckReport::fromArray($old), 'the default key is "version"');
|
||||
}
|
||||
|
||||
public function testTheStoreKeepsTheDiviOptionAndFormat(): void
|
||||
{
|
||||
$this->options['lit_mcp_divi_check_report'] = self::oldDiviReport();
|
||||
$store = new CheckReportStore('divi', 'divi_version');
|
||||
|
||||
$report = $store->latest();
|
||||
self::assertSame('r1', $report?->id);
|
||||
|
||||
$store->save($report);
|
||||
self::assertSame(self::oldDiviReport(), $this->options['lit_mcp_divi_check_report']);
|
||||
}
|
||||
|
||||
public function testTheStoresOfAProfileUseItsIdAndVersionKey(): void
|
||||
{
|
||||
$profile = new Profile(
|
||||
id: 'divi',
|
||||
label: 'Divi',
|
||||
tier: Tier::ReportOnly,
|
||||
capabilities: [FakeCapability::Read],
|
||||
reportVersionKey: 'divi_version'
|
||||
);
|
||||
$this->options['lit_mcp_divi_check_report'] = self::oldDiviReport();
|
||||
$this->options['lit_mcp_divi_releases'] = [[
|
||||
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3,
|
||||
'released_at' => '2026-10-01T08:00:00+00:00', 'report_id' => 'r',
|
||||
]];
|
||||
|
||||
self::assertSame('r1', CheckReportStore::forProfile($profile)->latest()?->id);
|
||||
self::assertSame('r', ReleaseStore::forProfile($profile)->find('5.2.2', '0.6.0')['report_id'] ?? null);
|
||||
}
|
||||
|
||||
public function testStoredReportReadsTheDiviFormatForAProfileWithItsKey(): void
|
||||
{
|
||||
$profile = new Profile(
|
||||
id: 'divi',
|
||||
label: 'Divi',
|
||||
tier: Tier::ReportOnly,
|
||||
capabilities: [FakeCapability::Read],
|
||||
reportVersionKey: 'divi_version'
|
||||
);
|
||||
$this->options['lit_mcp_divi_check_report'] = self::oldDiviReport();
|
||||
|
||||
self::assertSame('5.3.0', Compatibility::storedReport($profile)?->version);
|
||||
}
|
||||
|
||||
public function testTheStoreUsesAnOptionAndVersionKeyPerProfile(): void
|
||||
{
|
||||
$store = new CheckReportStore('pods');
|
||||
self::assertNull($store->latest());
|
||||
|
||||
$store->save(self::report(self::green()));
|
||||
|
||||
self::assertSame(['lit_mcp_pods_check_report'], array_keys($this->options));
|
||||
self::assertSame('5.3.0', $this->options['lit_mcp_pods_check_report']['version']);
|
||||
self::assertSame('5.3.0', $store->latest()?->version);
|
||||
self::assertNull((new CheckReportStore('divi', 'divi_version'))->latest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Compatibility;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Closure;
|
||||
use LIT\MCP\Compatibility\CheckStep;
|
||||
use LIT\MCP\Compatibility\Compatibility;
|
||||
use LIT\MCP\Compatibility\CompatibilityCheck;
|
||||
use LIT\MCP\Compatibility\Manifest;
|
||||
use LIT\MCP\Compatibility\ManifestCheck;
|
||||
use LIT\MCP\Compatibility\Mirror;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Plugin;
|
||||
use LIT\MCP\Tests\Support\FakeCapability;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use RuntimeException;
|
||||
|
||||
final class CompatibilityCheckTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('wp_generate_uuid4')->justReturn('uuid-1');
|
||||
}
|
||||
|
||||
/** @param list<Manifest> $manifests */
|
||||
private static function profile(array $manifests = [], ?Closure $extraSteps = null, ?Closure $stateKey = null): Profile
|
||||
{
|
||||
return new Profile(
|
||||
id: 'fake',
|
||||
label: 'Fake',
|
||||
tier: Tier::LockOnFailure,
|
||||
capabilities: [FakeCapability::Read],
|
||||
manifests: $manifests,
|
||||
version: static fn (): ?string => '2.1.0',
|
||||
stateKey: $stateKey,
|
||||
extraSteps: $extraSteps
|
||||
);
|
||||
}
|
||||
|
||||
private static function withMirrors(): Manifest
|
||||
{
|
||||
return new Manifest('Fake', mirrors: [new Mirror('m', 'f.php', 'fn', null, 'x')]);
|
||||
}
|
||||
|
||||
/** @param array<string, Closure(): bool> $code */
|
||||
private static function check(Profile $profile, array $code = [], array $mirrors = []): ManifestCheck
|
||||
{
|
||||
$check = Mockery::mock(ManifestCheck::class);
|
||||
$check->allows('codeChecks')->andReturn($code);
|
||||
$check->allows('registrationChecks')->andReturn([]);
|
||||
$check->allows('mirrorChecks')->andReturn($mirrors);
|
||||
|
||||
return $check;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function keys(CompatibilityCheck $check): array
|
||||
{
|
||||
return array_map(static fn (CheckStep $step): string => $step->key, $check->run(7)->steps);
|
||||
}
|
||||
|
||||
public function testAProfileWithoutMirrorsGetsOnlyTheManifestStep(): void
|
||||
{
|
||||
$profile = self::profile([new Manifest('Fake')]);
|
||||
$report = (new CompatibilityCheck($profile, self::check($profile)))->run(7);
|
||||
|
||||
self::assertTrue($report->green());
|
||||
self::assertSame(['manifest'], array_map(static fn (CheckStep $s): string => $s->key, $report->steps));
|
||||
self::assertSame('Fake-Funktionen, Klassen und Registrierungen', $report->steps[0]->label());
|
||||
self::assertSame(['uuid-1', 7, '2.1.0', Plugin::VERSION, ''], [
|
||||
$report->id, $report->userId, $report->version, $report->pluginVersion, $report->stateKey,
|
||||
]);
|
||||
}
|
||||
|
||||
public function testAManifestWithMirrorsAddsTheFingerprintStep(): void
|
||||
{
|
||||
$profile = self::profile([self::withMirrors()], stateKey: static fn (string $v): string => 'key-' . $v);
|
||||
$check = new CompatibilityCheck($profile, self::check($profile, [], ['TB: Nachbau von y()' => static fn (): bool => false]));
|
||||
|
||||
$report = $check->run(7);
|
||||
|
||||
self::assertSame(['manifest', 'mirrors'], array_map(static fn (CheckStep $s): string => $s->key, $report->steps));
|
||||
self::assertSame(['TB: Nachbau von y()'], $report->step(CheckStep::MIRRORS)?->problems);
|
||||
self::assertSame('Fingerabdrücke nachgebauter Fake-Funktionen', $report->step(CheckStep::MIRRORS)?->label());
|
||||
self::assertSame('key-2.1.0', $report->stateKey);
|
||||
self::assertFalse($report->green());
|
||||
}
|
||||
|
||||
public function testExtraStepsFollowAndKnowWhetherTheManifestIsClean(): void
|
||||
{
|
||||
$seen = [];
|
||||
$extra = static function (bool $clean) use (&$seen): array {
|
||||
$seen[] = $clean;
|
||||
|
||||
return [new CheckStep('extra', [])];
|
||||
};
|
||||
$profile = self::profile([self::withMirrors()], $extra);
|
||||
|
||||
$clean = new CompatibilityCheck($profile, self::check($profile));
|
||||
$broken = new CompatibilityCheck($profile, self::check($profile, ['Fake: f()' => static fn (): bool => false]));
|
||||
|
||||
self::assertSame(['manifest', 'mirrors', 'extra'], self::keys($clean));
|
||||
self::assertSame(['manifest', 'mirrors', 'extra'], self::keys($broken));
|
||||
self::assertSame([true, false], $seen);
|
||||
}
|
||||
|
||||
public function testAThrowingManifestCheckIsARedStep(): void
|
||||
{
|
||||
$profile = self::profile([new Manifest('Fake')]);
|
||||
$manifests = Mockery::mock(ManifestCheck::class);
|
||||
$manifests->allows('codeChecks')->andThrow(new RuntimeException('kaputt'));
|
||||
|
||||
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||||
$previous = ini_set('error_log', $log);
|
||||
try {
|
||||
$report = (new CompatibilityCheck($profile, $manifests))->run(7);
|
||||
$logged = (string) file_get_contents($log);
|
||||
} finally {
|
||||
ini_set('error_log', (string) $previous);
|
||||
unlink($log);
|
||||
}
|
||||
|
||||
self::assertSame(
|
||||
['Prüfschritt abgebrochen (Details im PHP-Error-Log).'],
|
||||
$report->step(CheckStep::MANIFEST)?->problems
|
||||
);
|
||||
self::assertStringContainsString('kaputt', $logged, 'the exception text goes to the error log');
|
||||
}
|
||||
|
||||
public function testForProfileUsesTheManifestCheckOfTheProfile(): void
|
||||
{
|
||||
$profile = new Profile(
|
||||
id: 'fake',
|
||||
label: 'Fake',
|
||||
tier: Tier::ReportOnly,
|
||||
capabilities: [FakeCapability::Read],
|
||||
manifestCheck: static fn (): ManifestCheck => new ManifestCheck(
|
||||
[new Manifest('Fake', [new \LIT\MCP\Compatibility\Call('lit_mcp_missing_fn', 0)])],
|
||||
signatures: false
|
||||
)
|
||||
);
|
||||
|
||||
$report = CompatibilityCheck::forProfile($profile)->run(1);
|
||||
|
||||
self::assertSame(['Fake: lit_mcp_missing_fn()'], $report->step(CheckStep::MANIFEST)?->problems);
|
||||
}
|
||||
|
||||
public function testCompatibilityChecksAProfileWithoutManifestCheckClosureByItsManifests(): void
|
||||
{
|
||||
$profile = self::profile([new Manifest('Fake', [new \LIT\MCP\Compatibility\Call('lit_mcp_missing_fn', 0)])]);
|
||||
|
||||
$report = (new Compatibility([$profile]))->check($profile, 3);
|
||||
|
||||
self::assertSame(3, $report->userId);
|
||||
self::assertSame(['Fake: lit_mcp_missing_fn()'], $report->step(CheckStep::MANIFEST)?->problems);
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,16 @@ final class CompatibilityComponentTest extends TestCase
|
||||
private function profile(bool $active, bool $broken = false): Profile
|
||||
{
|
||||
return new Profile(
|
||||
'fake',
|
||||
'Fake Plugin',
|
||||
Tier::LockOnFailure,
|
||||
[FakeCapability::Read, FakeCapability::Write],
|
||||
[new Manifest(
|
||||
id: 'fake',
|
||||
label: 'Fake Plugin',
|
||||
tier: Tier::LockOnFailure,
|
||||
capabilities: [FakeCapability::Read, FakeCapability::Write],
|
||||
manifests: [new Manifest(
|
||||
'Fake',
|
||||
calls: $broken ? [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])] : []
|
||||
)],
|
||||
static fn (): bool => $active,
|
||||
static fn (): string => '4.2.0'
|
||||
active: static fn (): bool => $active,
|
||||
version: static fn (): string => '4.2.0'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,10 +85,10 @@ final class CompatibilityComponentTest extends TestCase
|
||||
public function testAReleasePerVersionProfileHasItsOwnComponent(): void
|
||||
{
|
||||
$profile = new Profile(
|
||||
'rel',
|
||||
'Rel',
|
||||
Tier::ReleasePerVersion,
|
||||
[FakeCapability::Other],
|
||||
id: 'rel',
|
||||
label: 'Rel',
|
||||
tier: Tier::ReleasePerVersion,
|
||||
capabilities: [FakeCapability::Other],
|
||||
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other])
|
||||
);
|
||||
|
||||
@@ -101,10 +101,10 @@ final class CompatibilityComponentTest extends TestCase
|
||||
{
|
||||
$plain = $this->profile(true);
|
||||
$release = new Profile(
|
||||
'rel',
|
||||
'Rel',
|
||||
Tier::ReleasePerVersion,
|
||||
[FakeCapability::Other],
|
||||
id: 'rel',
|
||||
label: 'Rel',
|
||||
tier: Tier::ReleasePerVersion,
|
||||
capabilities: [FakeCapability::Other],
|
||||
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other])
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Tests\Unit\Compatibility;
|
||||
|
||||
use LIT\MCP\Compatibility\CompatibilityMatrix;
|
||||
use LIT\MCP\Compatibility\ManifestCheck;
|
||||
use LIT\MCP\Compatibility\Profile;
|
||||
use LIT\MCP\Compatibility\Tier;
|
||||
use LIT\MCP\Tests\Support\FakeCapability;
|
||||
@@ -17,10 +18,10 @@ final class ProfileTest extends TestCase
|
||||
{
|
||||
$matrix = static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Read]);
|
||||
$profile = new Profile(
|
||||
'fake',
|
||||
'Fake Plugin',
|
||||
Tier::ReleasePerVersion,
|
||||
[FakeCapability::Read],
|
||||
id: 'fake',
|
||||
label: 'Fake Plugin',
|
||||
tier: Tier::ReleasePerVersion,
|
||||
capabilities: [FakeCapability::Read],
|
||||
matrix: $matrix,
|
||||
minVersion: '2.0'
|
||||
);
|
||||
@@ -34,12 +35,54 @@ final class ProfileTest extends TestCase
|
||||
self::assertSame([], $profile->manifests);
|
||||
}
|
||||
|
||||
public function testTheStoredVersionKeyDefaultsToVersionAndIsKeptWhenGiven(): void
|
||||
{
|
||||
$plain = new Profile(id: 'fake', label: 'Fake', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read]);
|
||||
$divi = new Profile(
|
||||
id: 'fake',
|
||||
label: 'Fake',
|
||||
tier: Tier::ReportOnly,
|
||||
capabilities: [FakeCapability::Read],
|
||||
reportVersionKey: 'divi_version'
|
||||
);
|
||||
|
||||
self::assertSame('version', $plain->reportVersionKey);
|
||||
self::assertSame('divi_version', $divi->reportVersionKey);
|
||||
}
|
||||
|
||||
public function testTheReleaseFieldsAreOptionalAndKeptWhenGiven(): void
|
||||
{
|
||||
$bare = new Profile(id: 'fake', label: 'Fake', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read]);
|
||||
self::assertSame([], $bare->testedVersions);
|
||||
self::assertNull($bare->stateKey);
|
||||
self::assertNull($bare->extraSteps);
|
||||
self::assertNull($bare->manifestCheck);
|
||||
|
||||
$stateKey = static fn (string $version): string => 'k' . $version;
|
||||
$extraSteps = static fn (bool $manifestClean): array => [];
|
||||
$manifestCheck = static fn (): ManifestCheck => new ManifestCheck([], signatures: false);
|
||||
$full = new Profile(
|
||||
id: 'fake',
|
||||
label: 'Fake',
|
||||
tier: Tier::ReportOnly,
|
||||
capabilities: [FakeCapability::Read],
|
||||
testedVersions: ['1.0'],
|
||||
stateKey: $stateKey,
|
||||
extraSteps: $extraSteps,
|
||||
manifestCheck: $manifestCheck
|
||||
);
|
||||
self::assertSame(['1.0'], $full->testedVersions);
|
||||
self::assertSame($stateKey, $full->stateKey);
|
||||
self::assertSame($extraSteps, $full->extraSteps);
|
||||
self::assertSame($manifestCheck, $full->manifestCheck);
|
||||
}
|
||||
|
||||
public function testAReleasePerVersionProfileWithoutMatrixIsAProgrammingError(): void
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('needs a matrix closure');
|
||||
|
||||
new Profile('fake', 'Fake', Tier::ReleasePerVersion, [FakeCapability::Read]);
|
||||
new Profile(id: 'fake', label: 'Fake', tier: Tier::ReleasePerVersion, capabilities: [FakeCapability::Read]);
|
||||
}
|
||||
|
||||
public function testCapabilityIdsMustBeUniqueWithinAProfile(): void
|
||||
@@ -47,7 +90,7 @@ final class ProfileTest extends TestCase
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('fake-read');
|
||||
|
||||
new Profile('fake', 'Fake', Tier::ReportOnly, [FakeCapability::Read, FakeCapability::Read]);
|
||||
new Profile(id: 'fake', label: 'Fake', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read, FakeCapability::Read]);
|
||||
}
|
||||
|
||||
public function testTheTiersHaveStableValues(): void
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Compatibility;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Compatibility\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 testAddsFindsAndWithdrawsPerVersionAndPluginVersion(): void
|
||||
{
|
||||
$store = new ReleaseStore('divi', 'divi_version');
|
||||
$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 testDiviKeepsOptionAndFormat(): void
|
||||
{
|
||||
$this->options['lit_mcp_divi_releases'] = [[
|
||||
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3,
|
||||
'released_at' => '2026-10-01T08:00:00+00:00', 'report_id' => 'r',
|
||||
]];
|
||||
$store = new ReleaseStore('divi', 'divi_version');
|
||||
|
||||
self::assertSame('r', $store->find('5.2.2', '0.6.0')['report_id'] ?? null);
|
||||
|
||||
$store->add('5.3.0', '0.6.0', 1, 'r1');
|
||||
$written = $this->options['lit_mcp_divi_releases'];
|
||||
self::assertSame(['lit_mcp_divi_releases'], array_keys($this->options));
|
||||
self::assertSame(
|
||||
['divi_version', 'plugin_version', 'user_id', 'released_at', 'report_id'],
|
||||
array_keys($written[1])
|
||||
);
|
||||
self::assertSame($this->options['lit_mcp_divi_releases'][0], [
|
||||
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3,
|
||||
'released_at' => '2026-10-01T08:00:00+00:00', 'report_id' => 'r',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testAnotherProfileGetsItsOwnOptionAndTheVersionKey(): void
|
||||
{
|
||||
$store = new ReleaseStore('pods');
|
||||
$store->add('3.3.9.2', '0.6.0', 1, 'r1');
|
||||
|
||||
self::assertSame(['lit_mcp_pods_releases'], array_keys($this->options));
|
||||
self::assertSame('3.3.9.2', $this->options['lit_mcp_pods_releases'][0]['version']);
|
||||
self::assertSame('r1', $store->find('3.3.9.2', '0.6.0')['report_id'] ?? null);
|
||||
self::assertSame([], (new ReleaseStore('divi', 'divi_version'))->entries());
|
||||
}
|
||||
|
||||
public function testIgnoresBrokenRows(): void
|
||||
{
|
||||
$this->options['lit_mcp_divi_releases'] = ['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('divi', 'divi_version'))->entries(), 'divi_version'));
|
||||
self::assertSame([], (new ReleaseStore('divi'))->entries(), 'rows hold divi_version, not version');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user