refactor(compat): Prüfbausteine nach src/Compatibility, Matrix und ManifestCheck generisch

Requirement, Call, ConstantRequirement, Mirror, Problem, FeatureStatus, CompatibilityMatrix, Manifest, ManifestCheck sowie Fingerprints und LineDiff liegen jetzt im divi-freien Kern. Neu: GlobalRequirement, CompatibilityMatrix::fromProblems(), ManifestCheck-Option signatures, DiviManifestCheck::create() und DiviAreas. Divi-Verhalten unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 12:03:30 +01:00
co-authored by Claude Opus 5.5
parent 9beb55e228
commit b511ef6348
92 changed files with 1185 additions and 636 deletions
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\Call;
use PHPUnit\Framework\TestCase;
final class CallTest extends TestCase
{
public static function sample(int $a, int $b = 0): void
{
}
private static function method(): string
{
return self::class . '::sample';
}
public function testAcceptsAnExistingFunctionWithAMatchingArgumentCount(): void
{
self::assertTrue((new Call('strlen', 1))->accepts());
self::assertTrue((new Call('sprintf', 5))->accepts(), 'variadic');
}
public function testRejectsAnArgumentCountThePlatformFunctionDoesNotTake(): void
{
self::assertFalse((new Call('strlen', 2))->accepts(), 'strlen takes one argument');
self::assertFalse((new Call('str_replace', 2))->accepts(), 'str_replace needs three');
}
public function testNullArgumentCountOnlyChecksExistence(): void
{
self::assertTrue((new Call('str_replace'))->accepts());
}
public function testMissingFunctionOrMethodIsRejected(): void
{
self::assertFalse((new Call('lit_mcp_missing_function'))->accepts());
self::assertFalse((new Call(self::class . '::missing'))->accepts());
self::assertFalse((new Call('Lit\Missing\Klass::method'))->accepts());
}
public function testChecksTheArgumentCountOfAMethod(): void
{
self::assertTrue((new Call(self::method(), 1))->accepts());
self::assertTrue((new Call(self::method(), 2))->accepts());
self::assertFalse((new Call(self::method(), 0))->accepts(), 'one required');
self::assertFalse((new Call(self::method(), 3))->accepts(), 'at most two');
}
}
@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\Capability;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\FeatureStatus;
use LIT\MCP\Compatibility\Problem;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use PHPUnit\Framework\TestCase;
final class CompatibilityMatrixTest extends TestCase
{
public function testAFeatureWithoutResultIsIncompatible(): void
{
$matrix = new CompatibilityMatrix(DiviFeature::cases(), [
'page-read' => 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(DiviFeature::cases(), 'database');
$broken = CompatibilityMatrix::allIncompatible(DiviFeature::cases(), '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(DiviFeature::cases())->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(DiviFeature::cases(), 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 testLockedNamesEveryUnusableFeatureOnceInTheGivenOrder(): void
{
$matrix = ReleaseMatrices::matrix(
[DiviFeature::LibraryWrite, DiviFeature::ThemeBuilderWrite, DiviFeature::Catalog],
ReleaseState::Incompatible
);
$locked = $matrix->locked([
DiviFeature::PageWrite,
DiviFeature::ThemeBuilderWrite,
DiviFeature::LibraryWrite,
DiviFeature::ThemeBuilderWrite,
]);
self::assertSame(
[DiviFeature::ThemeBuilderWrite, DiviFeature::LibraryWrite],
array_map(static fn (FeatureStatus $status): DiviFeature => $status->feature, $locked)
);
self::assertSame(ReleaseState::Incompatible, $locked[0]->state);
self::assertSame([], $matrix->locked([DiviFeature::PageWrite, DiviFeature::PageRead]));
self::assertSame([], $matrix->locked([]));
}
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<string, array{ReleaseState, list<string>}> $locked
* @return array<string, FeatureStatus>
*/
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<string, array{ReleaseState, list<string>}> $locked */
private static function matrix(array $locked): CompatibilityMatrix
{
return new CompatibilityMatrix(DiviFeature::cases(), self::features($locked));
}
private static function fakeCapability(string $id, bool $write = false): Capability
{
return new class ($id, $write) implements Capability {
public function __construct(private readonly string $id, private readonly bool $write)
{
}
public function id(): string
{
return $this->id;
}
public function label(): string
{
return 'Fake ' . $this->id;
}
public function isWrite(): bool
{
return $this->write;
}
};
}
public function testWorksForCapabilitiesOfAnotherIntegration(): void
{
$read = self::fakeCapability('pods-x');
$write = self::fakeCapability('pods-y', true);
$stranger = self::fakeCapability('pods-z');
$matrix = CompatibilityMatrix::uniform([$read, $write], ReleaseState::Untested, ['offen']);
self::assertSame(ReleaseState::Untested, $matrix->of($read)->state);
self::assertSame(['pods-x', 'pods-y'], array_column($matrix->toArray(), 'id'));
self::assertFalse($matrix->usable([$read, $write]));
self::assertSame(
[CompatibilityMatrix::MISSING],
$matrix->of($stranger)->problems,
'a capability the matrix does not know is incompatible (fail closed)'
);
self::assertSame(ReleaseState::Released, CompatibilityMatrix::allReleased([$read])->state());
self::assertSame(
ReleaseState::Incompatible,
CompatibilityMatrix::allIncompatible([$read], 'kaputt')->of($read)->state
);
}
public function testTheSourceConstantsLiveOnTheMatrix(): void
{
self::assertSame('code', CompatibilityMatrix::SOURCE_CODE);
self::assertSame('database', CompatibilityMatrix::SOURCE_DATABASE);
self::assertSame('auto', CompatibilityMatrix::SOURCE_AUTO);
}
public function testFromProblemsWeighsHardAndSoftProblemsPerCapability(): void
{
$a = self::fakeCapability('pods-a');
$b = self::fakeCapability('pods-b');
$c = self::fakeCapability('pods-c');
$problems = [
new Problem('hart', [$a], Problem::CALL),
new Problem('Signatur', [$b], Problem::SIGNATURE),
new Problem('Referenz', [$c], Problem::REFERENCE_MISSING),
];
$released = CompatibilityMatrix::fromProblems([$a, $b, $c], $problems, CompatibilityMatrix::SOURCE_CODE, false);
self::assertSame(ReleaseState::Incompatible, $released->of($a)->state, 'hard locks even a released version');
self::assertSame(['hart'], $released->of($a)->problems);
self::assertSame(CompatibilityMatrix::SOURCE_CODE, $released->of($b)->source);
$manual = CompatibilityMatrix::fromProblems([$a, $b, $c], $problems, null, false);
self::assertSame(ReleaseState::Untested, $manual->of($b)->state);
self::assertSame(['Signatur'], $manual->of($b)->problems);
$auto = CompatibilityMatrix::fromProblems([$a, $b, $c], $problems, null, true);
self::assertSame(ReleaseState::Incompatible, $auto->of($b)->state, 'changed signature');
self::assertSame(ReleaseState::Untested, $auto->of($c)->state, 'reference missing');
$clean = CompatibilityMatrix::fromProblems([$a], [new Problem('b', [$b], Problem::CALL)], null, true);
self::assertSame(ReleaseState::Released, $clean->of($a)->state);
self::assertSame(CompatibilityMatrix::SOURCE_AUTO, $clean->of($a)->source);
$everyone = CompatibilityMatrix::fromProblems([$a, $b], [new Problem('alle', [], Problem::CALL)], null, true);
self::assertSame([ReleaseState::Incompatible, ReleaseState::Incompatible], [
$everyone->of($a)->state,
$everyone->of($b)->state,
], 'a problem without capabilities concerns every one');
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\FeatureStatus;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use PHPUnit\Framework\TestCase;
final class FeatureStatusTest extends TestCase
{
public function testOnlyAReleasedFeatureIsUsable(): void
{
self::assertTrue((new FeatureStatus(DiviFeature::PageRead, ReleaseState::Released, [], 'auto'))->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());
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\LineDiff;
use PHPUnit\Framework\TestCase;
final class LineDiffTest extends TestCase
{
public function testShowsChangedLinesWithTwoLinesOfContext(): void
{
$old = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'];
$new = ['a', 'b', 'c', 'd', 'E', 'f', 'g', 'h', 'i', 'j'];
self::assertSame(
[' c', ' d', '- e', '+ E', ' f', ' g', ' h', ' i', '+ j'],
LineDiff::lines($old, $new)
);
}
public function testMarksAGapBetweenDistantChanges(): void
{
$old = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
$new = ['A', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'J'];
self::assertSame(['- a', '+ A', ' b', ' c', '…', ' h', ' i', '- j', '+ J'], LineDiff::lines($old, $new));
}
public function testEqualInputHasNoLines(): void
{
self::assertSame([], LineDiff::lines(['a', 'b'], ['a', 'b']));
}
}
@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use ArrayObject;
use Brain\Monkey\Functions;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\ConstantRequirement;
use LIT\MCP\Compatibility\GlobalRequirement;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Compatibility\Problem;
use LIT\MCP\Compatibility\SignatureFingerprint;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
use RuntimeException;
final class ManifestCheckTest extends TestCase
{
private function check(?\Closure $current = null): ManifestCheck
{
$manifests = [
'A' => new Manifest(
DiviAreas::PAGES,
calls: [
new Call('strlen', 1),
new Call('lit_mcp_missing_function', 0),
new Call(ArrayObject::class . '::count', 0),
new Call('Missing\Klass::run', 0),
new Call('str_replace'),
],
constants: ['PHP_VERSION', 'LIT_MCP_MISSING_CONSTANT'],
postTypes: ['et_pb_layout'],
taxonomies: ['scope'],
blockTypes: ['divi/global-layout'],
mirrors: [
new Mirror('same', 'a.php', 'a', null, 'gleich'),
new Mirror('changed', 'b.php', 'b', null, 'geändert'),
new Mirror('unreadable', 'c.php', 'c', null, 'fehlt'),
new Mirror('no-expectation', 'd.php', 'd', null, 'ohne Soll'),
]
),
];
$currentHashes = ['same' => 'h1', 'changed' => 'hX', 'no-expectation' => 'h4'];
return new ManifestCheck(
$manifests,
['same' => 'h1', 'changed' => 'h2', 'unreadable' => 'h3'],
$current ?? static fn (Mirror $mirror): ?string => $currentHashes[$mirror->id] ?? null,
static fn (string $name): bool => false
);
}
public function testCodeChecksCoverExistenceAndArgumentCount(): void
{
self::assertSame(
[
'Seiten: strlen()',
'Seiten: lit_mcp_missing_function()',
'Seiten: ArrayObject::count()',
'Seiten: Klass::run()',
'Seiten: str_replace()',
'Seiten: Konstante PHP_VERSION',
'Seiten: Konstante LIT_MCP_MISSING_CONSTANT',
],
array_keys($this->check()->codeChecks())
);
self::assertSame(
[
'Seiten: lit_mcp_missing_function()',
'Seiten: Klass::run()',
'Seiten: Konstante LIT_MCP_MISSING_CONSTANT',
],
ManifestCheck::failed($this->check()->codeChecks())
);
}
public function testAcceptsChecksTheArgumentCountTheAdapterPasses(): void
{
self::assertTrue(ManifestCheck::accepts(new Call('strlen', 1)));
self::assertFalse(ManifestCheck::accepts(new Call('strlen', 2)), 'strlen takes one argument');
self::assertFalse(ManifestCheck::accepts(new Call('str_replace', 2)), 'str_replace needs three');
self::assertTrue(ManifestCheck::accepts(new Call('sprintf', 5)), 'variadic');
self::assertTrue(ManifestCheck::accepts(new Call('str_replace')), 'existence only');
}
public function testMirrorChecksCompareWithTheExpectedFingerprints(): void
{
$checks = $this->check()->mirrorChecks();
self::assertSame(
[
'Seiten: Nachbau von a() – gleich',
'Seiten: Nachbau von b() – geändert',
'Seiten: Nachbau von c() – fehlt',
'Seiten: Nachbau von d() – ohne Soll',
],
array_keys($checks)
);
self::assertSame(
[
'Seiten: Nachbau von b() – geändert',
'Seiten: Nachbau von c() – fehlt',
'Seiten: Nachbau von d() – ohne Soll',
],
ManifestCheck::failed($checks)
);
}
public function testUnreadableMirrorIsAFailedCheck(): void
{
$check = $this->check(static function (): ?string {
throw new RuntimeException('Divi file gone');
});
self::assertCount(4, ManifestCheck::failed($check->mirrorChecks()));
}
public function testRegistrationChecksAskWordPressAndDivi(): void
{
Functions\when('post_type_exists')->justReturn(true);
Functions\when('taxonomy_exists')->justReturn(false);
$checks = $this->check()->registrationChecks();
self::assertSame(
['Seiten: Post-Typ et_pb_layout', 'Seiten: Taxonomie scope', 'Seiten: Block-Typ divi/global-layout'],
array_keys($checks)
);
self::assertSame(
['Seiten: Taxonomie scope', 'Seiten: Block-Typ divi/global-layout'],
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(
DiviAreas::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<Problem> $problems
* @return list<array{label: string, kind: string, features: list<string>}>
*/
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')
);
}
public function testWithoutSignaturesACallWithoutReferenceIsNoProblem(): void
{
$check = new ManifestCheck(
['A' => new Manifest(DiviAreas::PAGES, calls: [new Call('strlen', 1), new Call('trim', 1)])],
[],
null,
null,
false
);
self::assertSame([], $check->codeProblems());
}
public function testWithoutSignaturesAMissingCallStillFails(): void
{
$check = new ManifestCheck(
['A' => new Manifest(DiviAreas::PAGES, calls: [new Call('strlen', 2)])],
signatures: false
);
self::assertSame(['call'], array_column(self::rows($check->codeProblems()), 'kind'));
}
public function testAMissingGlobalIsAHardProblemWithItsFeatures(): void
{
$GLOBALS['lit_mcp_present_global'] = 1;
$GLOBALS['lit_mcp_null_global'] = null;
$check = new ManifestCheck(['A' => new Manifest(
DiviAreas::PAGES,
globals: [
new GlobalRequirement('lit_mcp_present_global'),
new GlobalRequirement('lit_mcp_null_global'),
new GlobalRequirement('lit_mcp_missing_global', [DiviFeature::Catalog]),
],
features: [DiviFeature::PageRead]
)], signatures: false);
try {
$problems = $check->problems();
$failed = ManifestCheck::failed($check->codeChecks());
} finally {
unset($GLOBALS['lit_mcp_present_global'], $GLOBALS['lit_mcp_null_global']);
}
self::assertSame(
[
['label' => 'Seiten: Globale Variable lit_mcp_null_global', 'kind' => 'global',
'features' => ['page-read']],
['label' => 'Seiten: Globale Variable lit_mcp_missing_global', 'kind' => 'global',
'features' => ['catalog']],
],
self::rows($problems)
);
self::assertTrue($problems[0]->isHard());
self::assertSame(
['Seiten: Globale Variable lit_mcp_null_global', 'Seiten: Globale Variable lit_mcp_missing_global'],
$failed
);
}
public function testWithoutCurrentClosureEveryMirrorIsUnreadable(): void
{
$check = new ManifestCheck(['A' => new Manifest(
DiviAreas::PAGES,
mirrors: [new Mirror('same', 'a.php', 'a', null, 'gleich')]
)], ['same' => 'h1']);
self::assertSame(['mirror'], array_column(self::rows($check->mirrorProblems()), 'kind'));
}
public function testBlockTypesWithoutKnowsBlockClosureRefuseToBuildTheCheck(): void
{
$this->expectException(LogicException::class);
new ManifestCheck(['A' => new Manifest(DiviAreas::PAGES, blockTypes: ['divi/x'])]);
}
public function testWithoutBlockTypesNoKnowsBlockClosureIsNeeded(): void
{
$check = new ManifestCheck(['A' => new Manifest(DiviAreas::PAGES, calls: [new Call('strlen', 1)])]);
self::assertSame([], $check->registrationChecks());
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\ConstantRequirement;
use LIT\MCP\Compatibility\GlobalRequirement;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use PHPUnit\Framework\TestCase;
final class ManifestTest extends TestCase
{
public function testCallsKnowTheirClassMemberAndLabel(): void
{
$function = new Call('et_pb_is_allowed', 1);
$method = new Call('ET\Builder\Packages\GlobalData\GlobalData::get_global_colors', 0);
self::assertNull($function->className());
self::assertSame('et_pb_is_allowed', $function->member());
self::assertSame('et_pb_is_allowed()', $function->label());
self::assertSame('ET\Builder\Packages\GlobalData\GlobalData', $method->className());
self::assertSame('get_global_colors', $method->member());
self::assertSame('GlobalData::get_global_colors()', $method->label());
self::assertSame('ET_Core_PageResource', (new Call('\ET_Core_PageResource::remove_static_resources', 3))->className());
}
public function testMirrorLabel(): void
{
$function = new Mirror('tb-api-save', 'includes/api.php', 'et_theme_builder_api_save', null, 'Vorlagenliste');
$method = new Mirror('vb-flags', 'x.php', 'update', 'SyncToServerController', 'Kennzeichen');
self::assertSame('et_theme_builder_api_save()', $function->label());
self::assertSame('SyncToServerController::update()', $method->label());
}
public function testNamesListEveryDeclaredDiviName(): void
{
$manifest = new Manifest(
DiviAreas::LIBRARY,
calls: [new Call('et_pb_is_allowed', 1), new Call('ET\Builder\GlobalLayout::is_global_layout_template', 1)],
constants: ['ET_BUILDER_PRODUCT_VERSION'],
postTypes: ['et_pb_layout'],
taxonomies: ['scope'],
blockTypes: ['divi/global-layout'],
keys: ['_et_pb_module_type']
);
self::assertSame(
['et_pb_is_allowed', 'ET\Builder\GlobalLayout', 'ET_BUILDER_PRODUCT_VERSION', 'et_pb_layout', 'scope',
'_et_pb_module_type'],
$manifest->names()
);
self::assertSame('Bibliothek', $manifest->area);
}
public function testPlainConstantNamesBecomeRequirementsWithALabel(): void
{
$manifest = new Manifest(
DiviAreas::CATALOG,
constants: ['ET_BUILDER_PRODUCT_VERSION', new ConstantRequirement('ET_OTHER', [DiviFeature::Roles])]
);
self::assertSame(
['ET_BUILDER_PRODUCT_VERSION', 'ET_OTHER'],
array_map(static fn (ConstantRequirement $constant): string => $constant->name, $manifest->constants)
);
self::assertSame('Konstante ET_OTHER', $manifest->constants[1]->label());
self::assertSame([], $manifest->constants[0]->features());
self::assertSame([DiviFeature::Roles], $manifest->constants[1]->features());
self::assertSame(['ET_BUILDER_PRODUCT_VERSION', 'ET_OTHER'], $manifest->names());
}
public function testRequirementsAreCallsConstantsAndMirrorsInThatOrder(): void
{
$call = new Call('et_pb_is_allowed', 1);
$mirror = new Mirror('m', 'a.php', 'a', null, 'Terme');
$manifest = new Manifest(DiviAreas::LIBRARY, calls: [$call], constants: ['ET_X'], mirrors: [$mirror]);
$requirements = $manifest->requirements();
self::assertCount(3, $requirements);
self::assertSame($call, $requirements[0]);
self::assertSame('Konstante ET_X', $requirements[1]->label());
self::assertSame($mirror, $requirements[2]);
}
public function testFeaturesOfPrefersTheOwnFeaturesOverTheManifestDefault(): void
{
$own = new Call('et_a', null, [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite]);
$inherited = new Call('et_b');
$mirror = new Mirror('m', 'a.php', 'a', null, 'x', [DiviFeature::LibraryWrite]);
$manifest = new Manifest(
DiviAreas::PAGES,
calls: [$own, $inherited],
constants: ['ET_X'],
mirrors: [$mirror],
features: [DiviFeature::PageRead]
);
self::assertSame([DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], $manifest->featuresOf($own));
self::assertSame([DiviFeature::PageRead], $manifest->featuresOf($inherited));
self::assertSame([DiviFeature::PageRead], $manifest->featuresOf($manifest->constants[0]));
self::assertSame([DiviFeature::LibraryWrite], $manifest->featuresOf($mirror));
}
public function testWithoutAnyFeaturesAnEntryHasNone(): void
{
$call = new Call('et_a');
self::assertSame([], (new Manifest(DiviAreas::PAGES, calls: [$call]))->featuresOf($call));
}
public function testGlobalsAreRequirementsAndNames(): void
{
$manifest = new Manifest(DiviAreas::PAGES, globals: [new GlobalRequirement('wp_cache_clear_cache')]);
self::assertSame('wp_cache_clear_cache', $manifest->names()[0]);
self::assertSame('Globale Variable wp_cache_clear_cache', $manifest->requirements()[0]->label());
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\Capability;
use LIT\MCP\Compatibility\Problem;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use PHPUnit\Framework\TestCase;
final class ProblemTest extends TestCase
{
public function testOnlyCallMirrorConstantAndResolverAreHard(): void
{
$hard = [];
foreach (['call', 'signature', 'mirror', 'constant', 'reference-missing', 'resolver'] as $kind) {
$hard[$kind] = (new Problem('x', [DiviFeature::PageWrite], $kind))->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(), DiviFeature::tryFrom(...)));
}
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, DiviFeature::tryFrom(...)), $case);
}
}
public function testFromArrayResolvesFeaturesThroughTheLookupClosure(): void
{
$pods = new class implements Capability {
public function id(): string
{
return 'pods-x';
}
public function label(): string
{
return 'Pods X';
}
public function isWrite(): bool
{
return false;
}
};
$lookup = static fn (string $id): ?Capability => $id === 'pods-x' ? $pods : null;
$problem = Problem::fromArray(['label' => 'x', 'kind' => 'call', 'features' => ['pods-x']], $lookup);
self::assertNotNull($problem);
self::assertSame([$pods], $problem->features);
self::assertTrue($problem->concerns($pods));
self::assertNull(
Problem::fromArray(['label' => 'x', 'kind' => 'call', 'features' => ['pods-x', 'page-read']], $lookup),
'one unknown ID discards the problem'
);
}
}
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use ArrayObject;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\SignatureFingerprint;
use PHPUnit\Framework\TestCase;
/** Stand-in for a Divi class: one baseline method and one variant per property the fingerprint covers. */
final class SignatureFixture
{
public static function base(int $id, string|array $data = [], &$ref = null, ...$rest): ?bool
{
return null;
}
/** Same signature, other parameter names and other default values: no change. */
public static function renamed(int $post, string|array $payload = '', &$out = 5, ...$more): ?bool
{
return null;
}
public static function extraRequired(int $id, string|array $data, &$ref = null, ...$rest): ?bool
{
return null;
}
public static function fewer(int $id, string|array $data = []): ?bool
{
return null;
}
public static function typeChanged(string $id, string|array $data = [], &$ref = null, ...$rest): ?bool
{
return null;
}
public static function unionWidened(int $id, string|array|int $data = [], &$ref = null, ...$rest): ?bool
{
return null;
}
public static function notNullable(int $id, string|array $data = [], &$ref = null, ...$rest): bool
{
return true;
}
public static function byValue(int $id, string|array $data = [], $ref = null, ...$rest): ?bool
{
return null;
}
public static function notVariadic(int $id, string|array $data = [], &$ref = null, $rest = null): ?bool
{
return null;
}
public static function defaultRemoved(int $id, string|array $data, &$ref, ...$rest): ?bool
{
return null;
}
public static function returnChanged(int $id, string|array $data = [], &$ref = null, ...$rest): ?int
{
return null;
}
public function notStatic(int $id, string|array $data = [], &$ref = null, ...$rest): ?bool
{
return null;
}
protected static function notPublic(int $id, string|array $data = [], &$ref = null, ...$rest): ?bool
{
return null;
}
public static function untyped($id, $data = [])
{
return null;
}
public static function mixedTyped(mixed $id, mixed $data = []): mixed
{
return null;
}
}
final class SignatureFingerprintTest extends TestCase
{
private const FIXTURE = SignatureFixture::class;
private static function method(string $name): Call
{
return new Call(self::FIXTURE . '::' . $name);
}
public function testIsASha256OfAnExistingFunctionAndStable(): void
{
$hash = SignatureFingerprint::of(new Call('strlen', 1));
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', (string) $hash);
self::assertSame($hash, SignatureFingerprint::of(new Call('strlen', 1)));
self::assertNotSame($hash, SignatureFingerprint::of(new Call('str_replace', 3)));
}
public function testMissingSymbolsGiveNull(): void
{
self::assertNull(SignatureFingerprint::of(new Call('lit_mcp_missing_function')));
self::assertNull(SignatureFingerprint::of(new Call('Missing\Klass::run')));
self::assertNull(SignatureFingerprint::of(new Call(ArrayObject::class . '::missing')));
self::assertNull(SignatureFingerprint::canonical(new Call('lit_mcp_missing_function')));
}
public function testCanonicalTextDescribesTheSignature(): void
{
self::assertSame(
'public static (int, array|string=, &mixed=, ...mixed): ?bool',
SignatureFingerprint::canonical(self::method('base'))
);
self::assertSame('function (string): int', SignatureFingerprint::canonical(new Call('strlen')));
self::assertSame(
'public (int, array|string=, &mixed=, ...mixed): ?bool',
SignatureFingerprint::canonical(self::method('notStatic'))
);
}
/** @return array<string, array{string}> */
public static function changedSignatures(): array
{
return [
'extra required parameter' => ['extraRequired'],
'fewer parameters' => ['fewer'],
'parameter type' => ['typeChanged'],
'wider union' => ['unionWidened'],
'return type loses null' => ['notNullable'],
'by reference lost' => ['byValue'],
'variadic lost' => ['notVariadic'],
'default removed' => ['defaultRemoved'],
'return type' => ['returnChanged'],
'no longer static' => ['notStatic'],
'no longer public' => ['notPublic'],
];
}
/** @dataProvider changedSignatures */
public function testAnyChangeOfTheInterfaceChangesTheHash(string $variant): void
{
self::assertNotSame(
SignatureFingerprint::of(self::method('base')),
SignatureFingerprint::of(self::method($variant)),
$variant
);
}
public function testRenamedParametersAndOtherDefaultValuesKeepTheHash(): void
{
self::assertSame(
SignatureFingerprint::of(self::method('base')),
SignatureFingerprint::of(self::method('renamed'))
);
}
public function testNoTypeReadsAsMixed(): void
{
self::assertSame(
SignatureFingerprint::of(self::method('untyped')),
SignatureFingerprint::of(self::method('mixedTyped'))
);
}
public function testTheCallsOwnArgumentCountAndFeaturesAreNotPartOfIt(): void
{
self::assertSame(
SignatureFingerprint::of(new Call(self::FIXTURE . '::base', 2)),
SignatureFingerprint::of(new Call('\\' . self::FIXTURE . '::base', null, []))
);
}
public function testAFunctionAndAMethodOfTheSameShapeDiffer(): void
{
self::assertNotSame(
SignatureFingerprint::of(new Call('strlen')),
SignatureFingerprint::of(new Call(ArrayObject::class . '::count'))
);
}
public function testTheClassNeverIncludesAFile(): void
{
$source = (string) file_get_contents(
dirname(__DIR__, 3) . '/src/Compatibility/SignatureFingerprint.php'
);
$forbidden = [T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE, T_EVAL];
foreach (token_get_all($source) as $token) {
self::assertFalse(is_array($token) && in_array($token[0], $forbidden, true), 'no include, require or eval');
}
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\SourceFingerprint;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class SourceFingerprintTest extends TestCase
{
/** Shaped like Divi's files: a guarded function, two classes with a method of the same name. */
private const SOURCE = <<<'PHP'
<?php
use function et_demo_save;
if ( ! function_exists( 'et_demo_save' ) ) {
/**
* Saves.
*/
function et_demo_save( $id, $data = array() ) {
$value = "{$data['x']}";
if ( $id ) {
update_post_meta( $id, '_et_demo', $value );
}
return Foo::class;
}
}
class Controller {
public static function update( $request ) {
return array( 'a' => 1 );
}
}
class Other {
public static function update( $request ) {
return array( 'b' => 2 );
}
}
PHP;
public function testFindsAFunctionInsideAFunctionExistsGuard(): void
{
$print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save');
self::assertNotNull($print);
self::assertSame(7, $print->startLine);
self::assertSame(13, $print->endLine);
self::assertStringStartsWith('function et_demo_save(', $print->source);
self::assertStringEndsWith('}', $print->source);
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $print->hash);
}
public function testClassConstantsAndInterpolationDoNotConfuseTheBraceCount(): void
{
$print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save');
self::assertNotNull($print);
self::assertStringContainsString('return Foo::class;', $print->source);
self::assertStringNotContainsString('class Controller', $print->source);
}
public function testMethodIsFoundInItsOwnClassOnly(): void
{
$controller = SourceFingerprint::fromSource(self::SOURCE, 'update', 'ET\Builder\VisualBuilder\Controller');
$other = SourceFingerprint::fromSource(self::SOURCE, 'update', 'Other');
self::assertNotNull($controller);
self::assertNotNull($other);
self::assertStringContainsString("'a' => 1", $controller->source);
self::assertStringContainsString("'b' => 2", $other->source);
self::assertNotSame($controller->hash, $other->hash);
self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'update'), 'a method is no top-level function');
self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save', 'Controller'));
self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'missing'));
}
public function testCommentsAndWhitespaceDoNotChangeTheHash(): void
{
$reformatted = str_replace(
["\t\t\$value = ", "if ( \$id ) {"],
["\t\t// Why: demo.\n\t\t\$value = ", "if (\$id)\n\t\t{"],
self::SOURCE
);
self::assertSame(
SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
SourceFingerprint::fromSource($reformatted, 'et_demo_save')?->hash
);
}
public function testACodeChangeChangesTheHash(): void
{
$changed = str_replace("'_et_demo'", "'_et_demo_v2'", self::SOURCE);
self::assertNotSame(
SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
SourceFingerprint::fromSource($changed, 'et_demo_save')?->hash
);
}
public function testOfReadsTheFileAndThrowsForMissingFunctions(): void
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit');
file_put_contents($file, self::SOURCE);
try {
self::assertSame(
SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
SourceFingerprint::of($file, 'et_demo_save')->hash
);
try {
SourceFingerprint::of($file, 'update', 'Missing');
self::fail('a missing method must throw');
} catch (RuntimeException $e) {
self::assertStringContainsString('Missing::update() not found', $e->getMessage());
}
} finally {
unlink($file);
}
}
public function testOfThrowsForAnUnreadableFile(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('is not readable');
SourceFingerprint::of('/nonexistent/lit-divi.php', 'et_demo_save');
}
}