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); } }