Files
WP-LIT-MCP/tests/Unit/Admin/CompatibilityTabTest.php
T
Jan-Niclas LoosenandClaude Opus 5.5 bee9da3a86 feat(cache): WP Fastest Cache statt WP Super Cache
Die Cache-Integration war für WP Super Cache gebaut, eingesetzt wird aber
WP Fastest Cache. Profil (wpfc), Listener, Drosselung, lit/cache-clear und
Leeren-Probe laufen jetzt über die öffentlichen WPFC-Funktionen
wpfc_clear_post_cache_by_id/wpfc_clear_all_cache; die Probe leert nur die
Wegwerf-Seite (singleDeleteCache ohne Elternseiten) und prüft, dass WPFC
den Hook noch verarbeitet. Smoke fastest-cache-smoke.sh ersetzt den WPSC-Smoke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:57:55 +01:00

1077 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Admin\AdminPostAction;
use LIT\MCP\Admin\AdminTab;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class CompatibilityTabTest extends TestCase
{
private const CAPS = [FakeCapability::Read, FakeCapability::Write];
private ?string $redirect = null;
/** @var list<string> The nonce actions check_admin_referer() was asked for. */
private array $nonces = [];
protected function setUp(): void
{
parent::setUp();
$this->redirect = null;
$this->nonces = [];
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('current_user_can')->justReturn(true);
Functions\when('check_admin_referer')->alias(function (string $action): int {
$this->nonces[] = $action;
return 1;
});
Functions\when('get_current_user_id')->justReturn(1);
Functions\when('wp_safe_redirect')->alias(function (string $url): bool {
$this->redirect = $url;
return true;
});
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://k.de/wp-admin/' . $path);
Functions\when('add_query_arg')->alias(
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
);
Functions\when('rest_url')->alias(static fn (string $path): string => 'https://k.de/wp-json/' . $path);
Functions\when('wp_create_nonce')->alias(static fn (string $action): string => 'nonce-' . $action);
Functions\when('wp_nonce_field')->alias(static function (string $action): void {
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
});
Functions\when('submit_button')->alias(
static function (
string $text,
string $type = 'primary',
string $name = 'submit',
bool $wrap = true,
array $attributes = []
): void {
$extra = '';
foreach ($attributes as $key => $value) {
$extra .= ' ' . $key . '="' . esc_attr($value) . '"'; // WordPress escapes the values itself
}
echo '<button class="button-' . $type . '" name="' . $name . '"' . $extra . '>' . $text . '</button>';
}
);
Functions\when('get_option')->alias(static fn (string $name): string => $name === 'date_format' ? 'Y-m-d' : '');
Functions\when('date_i18n')->alias(static fn (string $format, int $time): string => gmdate($format, $time));
Functions\when('get_userdata')->justReturn(false);
Functions\when('sanitize_key')->alias(
static fn (string $key): string => strtolower((string) preg_replace('/[^a-z0-9_\-]/i', '', $key))
);
Functions\when('wp_unslash')->returnArg();
}
protected function tearDown(): void
{
unset($_POST['profile'], $_GET['tab']);
parent::tearDown();
}
/** A profile of the invented plugin "fake"; $matrix only for ReleasePerVersion. */
private static function profile(
Tier $tier = Tier::LockOnFailure,
?CompatibilityMatrix $matrix = null,
bool $active = true,
?string $version = '1.2.0',
array $tested = [],
?Closure $stepLabel = null
): Profile {
return new Profile(
id: 'fake',
label: 'Fake <Plugin>',
tier: $tier,
capabilities: self::CAPS,
active: static fn (): bool => $active,
version: static fn (): ?string => $version,
matrix: $tier === Tier::ReleasePerVersion
? static fn (): CompatibilityMatrix => $matrix ?? self::fail('no matrix expected')
: null,
testedVersions: $tested,
stateKey: static fn (string $v): string => 'key' . $v,
stepLabel: $stepLabel
);
}
private static function matrix(ReleaseState $state, array $problems = []): CompatibilityMatrix
{
return CompatibilityMatrix::uniform(self::CAPS, $state, $problems, null);
}
private static function report(bool $green = true, string $version = '1.2.0', string $key = 'key1.2.0'): CheckReport
{
$steps = [
new CheckStep(CheckStep::MANIFEST, $green ? [] : ['Funktion <i>x</i> fehlt'], false, 'Manifest <b>Label</b>'),
new CheckStep('old', [], false),
];
return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, $version, Plugin::VERSION, $key, $steps);
}
private function tab(
Profile $profile,
?CheckReport $report = null,
?ReleaseStore $releases = null,
?FakeChangeLog $log = null
): CompatibilityTab {
$reports = Mockery::mock(CheckReportStore::class);
$reports->allows('latest')->andReturn($report);
$store = $releases ?? Mockery::mock(ReleaseStore::class);
if ($releases === null) {
$store->allows('entries')->andReturn([]);
$store->allows('find')->andReturn(null);
}
return new CompatibilityTab(
new Compatibility([$profile]),
$log ?? FakeChangeLog::forbidden(),
new AdminPostAction(static function (): void {
}),
static fn (): CheckReportStore => $reports,
static fn (): ReleaseStore => $store
);
}
/** The part of $html from the first $from to the next $to (the markers included). */
private static function between(string $html, string $from, string $to): string
{
$start = (int) strpos($html, $from);
return substr($html, $start, (int) strpos($html, $to, $start) - $start + strlen($to));
}
private static function html(CompatibilityTab $tab): string
{
ob_start();
$tab->render();
return (string) ob_get_clean();
}
/** One source for the ability name of the release and withdraw entries (FastestCacheListener reads it too). */
public function testTheReleaseAbilityNameHasOneSource(): void
{
self::assertSame('lit/divi-release', CompatibilityTab::releaseAbility('divi'));
self::assertTrue(CompatibilityTab::isReleaseAbility(CompatibilityTab::releaseAbility('wpfc')));
foreach (['lit/content-update', 'lit/-release', 'lit/release', 'lit/divi-releases'] as $other) {
self::assertFalse(CompatibilityTab::isReleaseAbility($other), $other);
}
}
public function testItIsTheTabCompatibility(): void
{
$tab = $this->tab(self::profile());
self::assertInstanceOf(AdminTab::class, $tab);
self::assertSame('compatibility', $tab->slug());
self::assertSame('Kompatibilität', $tab->label());
}
/** @param list<Profile> $profiles */
private function tabOver(array $profiles): CompatibilityTab
{
$reports = Mockery::mock(CheckReportStore::class);
$reports->allows('latest')->andReturn(null);
$store = Mockery::mock(ReleaseStore::class);
$store->allows('entries')->andReturn([]);
$store->allows('find')->andReturn(null);
return new CompatibilityTab(
new Compatibility($profiles),
FakeChangeLog::forbidden(),
new AdminPostAction(static function (): void {
}),
static fn (): CheckReportStore => $reports,
static fn (): ReleaseStore => $store
);
}
/** A second profile; $capability keeps the capability IDs unique across profiles. */
private static function other(
string $id,
bool $active,
Tier $tier = Tier::LockOnFailure,
FakeCapability $capability = FakeCapability::Write
): Profile {
return new Profile(
id: $id,
label: ucfirst($id),
tier: $tier,
capabilities: [$capability],
active: static fn (): bool => $active,
version: static fn (): ?string => '2.0.0',
matrix: $tier === Tier::ReleasePerVersion
? static fn (): CompatibilityMatrix => CompatibilityMatrix::uniform([$capability], ReleaseState::Released, [], null)
: null
);
}
public function testTheOverviewListsEveryProfileAndLinksToTheSectionsOfTheActiveOnes(): void
{
$html = self::html($this->tabOver([
self::other('alpha', true),
self::other('beta', false, capability: FakeCapability::Other),
self::other('gamma', true, capability: FakeCapability::Read),
]));
self::assertStringContainsString('<h2>Übersicht</h2>', $html);
foreach (['Alpha', 'Beta', 'Gamma'] as $label) {
self::assertStringContainsString('data-label="Plugin">' . $label . '</th>', $html);
}
self::assertStringContainsString('href="#lit-mcp-compat-alpha"', $html);
self::assertStringContainsString('href="#lit-mcp-compat-gamma"', $html);
self::assertStringNotContainsString('#lit-mcp-compat-beta', $html);
self::assertStringContainsString('id="lit-mcp-compat-alpha"', $html);
self::assertStringNotContainsString('id="lit-mcp-compat-beta"', $html);
self::assertSame(1, substr_count($html, '<strong>nicht aktiv</strong>'));
// A link target in the page for every link, and the link names its plugin after the visible word.
self::assertStringContainsString('>Details<span class="screen-reader-text"> zu Alpha</span></a>', $html);
}
public function testTheOverviewSaysAtOnceWhetherEverythingIsFine(): void
{
$fine = self::html($this->tabOver([self::other('alpha', true)]));
$attention = self::html($this->tabOver([
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested)),
]));
self::assertStringContainsString('notice-success inline', $fine);
self::assertStringContainsString('Alles in Ordnung', $fine);
self::assertStringContainsString('notice-warning inline', $attention);
self::assertStringContainsString('1 aktives Plugin braucht deine Aufmerksamkeit.', $attention);
}
public function testTheOverviewTableIsAccessibleAndResponsive(): void
{
$html = self::html($this->tabOver([self::other('alpha', true)]));
$table = self::between($html, 'lit-mcp-compat-overview', '</tbody></table>');
self::assertStringContainsString('<caption class="screen-reader-text">', $table);
self::assertSame(5, substr_count($table, '<th scope="col">'));
self::assertStringContainsString('<th scope="row" data-label="Plugin">Alpha</th>', $table);
foreach (['Version', 'Zustand', 'Freigabe', 'Details'] as $label) {
self::assertStringContainsString('<td data-label="' . $label . '">', $table);
}
self::assertStringContainsString('Sperrt automatisch bei Problemen', $table);
}
public function testTierNamesNeverReachTheScreen(): void
{
foreach ([Tier::ReleasePerVersion, Tier::LockOnFailure, Tier::ReportOnly] as $tier) {
$html = self::html($this->tabOver([self::other('alpha', true, $tier)]));
foreach (['LockOnFailure', 'ReleasePerVersion', 'ReportOnly'] as $raw) {
self::assertStringNotContainsString($raw, $html);
}
}
}
public function testASectionShowsStateThenActionsThenDetails(): void
{
$html = self::html($this->tab(
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested)),
self::report()
));
$section = substr($html, (int) strpos($html, '<section'));
self::assertStringContainsString('aria-labelledby="lit-mcp-compat-fake-title"', $section);
self::assertStringContainsString('<h2 id="lit-mcp-compat-fake-title">Fake &lt;Plugin&gt;</h2>', $section);
$order = array_map(
static fn (string $needle): int => (int) strpos($section, $needle),
['notice notice-warning inline', '<h3>Aktionen</h3>', '<details', '<h3>Fähigkeiten</h3>', '<h3>Versionen</h3>', '<h3>Letzte Prüfung</h3>']
);
$sorted = $order;
sort($sorted);
self::assertSame($sorted, $order);
self::assertNotContains(0, $order);
}
public function testStateAndNextStepAreWordsAndNeverOnlyColour(): void
{
foreach (self::states() as [$state, $word]) {
$html = self::html($this->tab(self::profile(Tier::ReleasePerVersion, self::matrix($state))));
// overview row, section notice and one row per capability: the word beside an icon hidden from AT
$expected = 2 + count(self::CAPS);
self::assertSame($expected, substr_count($html, '<strong>' . $word . '</strong>'));
self::assertSame($expected, substr_count($html, 'aria-hidden="true"></span> <strong>' . $word . '</strong>'));
}
}
public function testTheFeatureTableNamesEachRowByItsCapability(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('<th scope="row" data-label="Fähigkeit">Fake fake-read</th>', $html);
self::assertStringContainsString('<td data-label="Zustand">', $html);
}
public function testTheDetailsSummaryNamesThePlugin(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString(
'<summary>Details<span class="screen-reader-text"> zu Fake &lt;Plugin&gt;</span></summary>',
$html
);
}
public function testProblemsStandInTheStateNoticeEscaped(): void
{
$html = self::html($this->tab(
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Incompatible, ['Problem <A>']))
));
self::assertStringContainsString('notice notice-error inline', $html);
self::assertStringContainsString('<li>Problem &lt;A&gt;</li>', $html);
}
public function testAnInactiveProfileIsOneLineAndNeverAsksForTheMatrix(): void
{
Actions\expectDone('lit_mcp_compat_profile_extra')->never();
// ReleasePerVersion with a matrix closure that fails the test when called.
$html = self::html($this->tab(self::profile(Tier::ReleasePerVersion, null, active: false)));
self::assertStringContainsString('<strong>nicht aktiv</strong>', $html);
self::assertStringContainsString('data-label="Plugin">Fake &lt;Plugin&gt;</th>', $html);
self::assertStringContainsString('Kein unterstütztes Plugin ist aktiv', $html);
self::assertStringNotContainsString('<section', $html);
self::assertStringNotContainsString('<form', $html);
self::assertStringNotContainsString('href="#lit-mcp-compat-fake"', $html);
self::assertSame(1, substr_count($html, '<table'));
}
/** @dataProvider states */
public function testTheOverallStateIsShownAsWordAndIcon(ReleaseState $state, string $word, string $icon): void
{
$html = self::html($this->tab(self::profile(Tier::ReleasePerVersion, self::matrix($state, ['Problem A']))));
self::assertStringContainsString('<strong>' . $word . '</strong>', $html);
self::assertStringContainsString('dashicons-' . $icon, $html);
self::assertStringContainsString('aria-hidden="true"', $html);
}
/** @return array<string, array{ReleaseState, string, string}> */
public static function states(): array
{
return [
'released' => [ReleaseState::Released, 'freigegeben', 'yes-alt'],
'untested' => [ReleaseState::Untested, 'ungetestet', 'warning'],
'incompatible' => [ReleaseState::Incompatible, 'inkompatibel', 'dismiss'],
];
}
public function testItShowsTheVersionAndTheProblemsAndTheCapabilityTable(): void
{
$html = self::html($this->tab(
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Incompatible, ['Problem A']))
));
self::assertStringContainsString('1.2.0', $html);
self::assertStringContainsString('Problem A', $html);
self::assertStringContainsString('Fake fake-read', $html);
self::assertStringContainsString('Fake fake-write', $html);
}
/** @dataProvider tierTexts */
public function testTheTierHintSaysWhatTheReleaseMeans(Tier $tier, string $text): void
{
$matrix = $tier === Tier::ReleasePerVersion ? self::matrix(ReleaseState::Released) : null;
$html = self::html($this->tab(self::profile($tier, $matrix)));
self::assertStringContainsString($text, $html);
}
/** @return array<string, array{Tier, string}> */
public static function tierTexts(): array
{
return [
'release' => [Tier::ReleasePerVersion, 'Freigabe pro Version nötig'],
'lock' => [Tier::LockOnFailure, 'Sperrt automatisch bei Problemen'],
'report' => [Tier::ReportOnly, 'Nur Anzeige'],
];
}
public function testLockOnFailureIsLockedWhenTheProfileIsActiveButTooOld(): void
{
$profile = new Profile(
id: 'fake',
label: 'Fake',
tier: Tier::LockOnFailure,
capabilities: self::CAPS,
version: static fn (): string => '0.9',
minVersion: '1.0'
);
$tab = new CompatibilityTab(new Compatibility([$profile]), FakeChangeLog::forbidden());
Functions\when('get_option')->justReturn(false);
$html = self::html($tab);
self::assertStringContainsString('<strong>inkompatibel</strong>', $html);
self::assertStringContainsString('Mindestversion', $html);
}
public function testTestedAndReleasedVersionsAreListed(): void
{
$releases = Mockery::mock(ReleaseStore::class);
$releases->allows('entries')->andReturn([
['version' => '1.1.0', 'plugin_version' => '0.7.0', 'user_id' => 1, 'released_at' => 'x', 'report_id' => 'r'],
]);
$releases->allows('find')->andReturn(null);
$html = self::html($this->tab(self::profile(tested: ['1.0.0', '1.0.1']), null, $releases));
self::assertStringContainsString('1.0.0, 1.0.1', $html);
self::assertStringContainsString('1.1.0', $html);
self::assertStringContainsString('LIT MCP 0.7.0', $html);
}
public function testNoVersionListsShowADashWhenEmpty(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('<th scope="row">Im Plugin getestet</th>', $html);
self::assertStringContainsString('<th scope="row">Von dir freigegeben</th>', $html);
self::assertSame(2, substr_count($html, '<td>–</td>'));
}
public function testTheLatestReportListsEveryStepWithIconAndLabel(): void
{
$profile = self::profile(stepLabel: static fn (CheckStep $s): string => $s->label ?? 'Alt:' . $s->key);
$html = self::html($this->tab($profile, self::report()));
self::assertStringContainsString('Letzte Prüfung', $html);
self::assertStringContainsString('Manifest &lt;b&gt;Label&lt;/b&gt;', $html);
self::assertStringContainsString('Alt:old', $html);
self::assertStringContainsString('<strong>bestanden</strong>', $html);
}
public function testAFailedStepSaysSoInVisibleText(): void
{
$html = self::html($this->tab(self::profile(), self::report(false)));
self::assertStringContainsString('<strong>fehlgeschlagen</strong>', $html);
}
public function testTheCapabilityTableHasACaptionNamingTheProfile(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString(
'<caption class="screen-reader-text">Fähigkeiten von Fake &lt;Plugin&gt;</caption>',
$html
);
}
public function testAProfileCanAddColumnsToTheCapabilityTableAndTheTabEscapesThem(): void
{
Filters\expectApplied(CompatibilityTab::COLUMNS_FILTER)->once()->with([], 'fake')->andReturn([
[
'heading' => 'Quelle <b>',
'cell' => static fn (FakeCapability $c, CompatibilityMatrix $m): array => $c === FakeCapability::Read
? ['<i>a</i>']
: [],
],
[
'heading' => 'Tools',
'cell' => static fn (FakeCapability $c, CompatibilityMatrix $m): array => ['tool-' . $c->id()],
'code' => true,
],
]);
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('<th scope="col">Quelle &lt;b&gt;</th>', $html);
self::assertStringContainsString('<td data-label="Quelle &lt;b&gt;"><ul><li>&lt;i&gt;a&lt;/i&gt;</li></ul></td>', $html);
self::assertStringContainsString('<td data-label="Quelle &lt;b&gt;">–</td>', $html);
self::assertStringContainsString('<li><code>tool-' . FakeCapability::Read->id() . '</code></li>', $html);
self::assertStringNotContainsString('<i>a</i>', $html);
}
public function testTheCapabilityTableIsOpenUnlessReleasedAndFolded(): void
{
$released = self::html($this->tab(
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released))
));
$untested = self::html($this->tab(
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested))
));
self::assertStringContainsString('<details class="lit-mcp-compat-details">', $released);
self::assertStringContainsString('<details class="lit-mcp-compat-details" open>', $untested);
self::assertStringContainsString('lit-mcp-compat-features', $released);
}
public function testEveryButtonNamesItsProfileForAssistiveTechnology(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$withdrawable = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released));
$html = self::html($this->tab($profile, self::report()))
. self::html($this->tab($withdrawable, self::report(), self::store(self::entry())));
self::assertStringContainsString('aria-label="Prüfung starten: Fake &lt;Plugin&gt;"', $html);
// The release label names the profile itself.
self::assertStringContainsString('>Fake <Plugin> 1.2.0 freigeben</button>', $html);
self::assertStringContainsString('aria-label="Freigabe zurückziehen: Fake &lt;Plugin&gt;"', $html);
}
public function testEachFormButtonHasAUniqueNameInsteadOfSubmit(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$withdrawable = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released));
$html = self::html($this->tab($profile, self::report()))
. self::html($this->tab($withdrawable, self::report(), self::store(self::entry())));
self::assertStringNotContainsString('name="submit"', $html);
self::assertStringContainsString('name="lit-mcp-compat-release-fake"', $html);
self::assertStringContainsString('name="lit-mcp-compat-withdraw-fake"', $html);
self::assertStringContainsString('name="lit-mcp-compat-check-fake"', $html);
}
public function testTheLoadHookIsRegisteredOnTheSettingsPage(): void
{
$tab = $this->tab(self::profile());
Actions\expectAdded('load-settings_page_lit-mcp')->once()->with([$tab, 'loadProfiles']);
$tab->registerActions();
}
public function testTheLoadActionFiresPerProfileOnTheOwnTabOnly(): void
{
$_GET['tab'] = 'compatibility';
Actions\expectDone('lit_mcp_compat_profile_load')->once()->with('fake');
$this->tab(self::profile())->loadProfiles();
}
public function testTheLoadActionStaysQuietOnOtherTabsAndForOthersThanAdministrators(): void
{
Actions\expectDone('lit_mcp_compat_profile_load')->never();
foreach (['verbindung', '', ['compatibility']] as $tabParam) {
$_GET['tab'] = $tabParam;
$this->tab(self::profile())->loadProfiles();
}
unset($_GET['tab']);
$this->tab(self::profile())->loadProfiles();
$_GET['tab'] = 'compatibility';
Functions\when('current_user_can')->justReturn(false);
$this->tab(self::profile())->loadProfiles();
}
public function testRenderingDoesNotFireTheLoadAction(): void
{
$_GET['tab'] = 'compatibility';
Actions\expectDone('lit_mcp_compat_profile_load')->never();
self::html($this->tab(self::profile()));
}
public function testAnArrayAsProfileFieldIsRefusedWithoutAWarning(): void
{
$this->actionsDie();
$_POST['profile'] = ['fake'];
$this->expectException(\RuntimeException::class);
$this->tab(self::profile())->release();
}
public function testASkippedStepIsMarkedAsNotRun(): void
{
$report = new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, '1.2.0', Plugin::VERSION, 'k', [
new CheckStep('write', [], true, 'Schreibtest'),
]);
$html = self::html($this->tab(self::profile(), $report));
self::assertStringContainsString('Schreibtest', $html);
self::assertStringContainsString('<strong>übersprungen</strong>', $html);
self::assertStringNotContainsString('screen-reader-text">übersprungen', $html);
self::assertStringContainsString('nicht ausgeführt', $html);
}
public function testAStepWithoutLabelAndWithoutLabelSourceShowsItsKey(): void
{
$html = self::html($this->tab(self::profile(), self::report()));
self::assertStringContainsString('</strong>: old', $html);
}
public function testAFailedStepListsItsProblems(): void
{
$html = self::html($this->tab(self::profile(), self::report(false)));
self::assertStringContainsString('fehlgeschlagen', $html);
self::assertStringContainsString('Funktion &lt;i&gt;x&lt;/i&gt; fehlt', $html);
}
public function testWithoutAReportNothingIsListed(): void
{
self::assertStringNotContainsString('Letzte Prüfung', self::html($this->tab(self::profile())));
}
public function testTheProfileExtraHookRunsLastWithTheProfileId(): void
{
Actions\expectDone('lit_mcp_compat_profile_extra')->once()->with('fake');
self::html($this->tab(self::profile(), self::report()));
}
public function testTheProfileIntroRunsBeforeTheCapabilityTableAndTheExtraAfterIt(): void
{
Actions\expectDone(CompatibilityTab::INTRO_ACTION)->once()->with('fake')->whenHappen(static function (): void {
echo '[INTRO]';
});
Actions\expectDone(CompatibilityTab::EXTRA_ACTION)->once()->with('fake')->whenHappen(static function (): void {
echo '[EXTRA]';
});
$html = self::html($this->tab(self::profile(), self::report()));
self::assertLessThan(strpos($html, 'lit-mcp-compat-details'), strpos($html, '[INTRO]'));
self::assertGreaterThan(strpos($html, '</details>'), strpos($html, '[EXTRA]'));
}
public function testAnInactiveProfileFiresNeitherIntroNorExtra(): void
{
Actions\expectDone(CompatibilityTab::INTRO_ACTION)->never();
Actions\expectDone(CompatibilityTab::EXTRA_ACTION)->never();
self::html($this->tab(self::profile(active: false)));
}
public function testAProfileCanReplaceTheGenericExplanationAndTheTabEscapesIt(): void
{
Filters\expectApplied(CompatibilityTab::EXPLANATION_FILTER)
->once()
->with('Alle Fähigkeiten sind nutzbar.', 'fake', ReleaseState::Released)
->andReturn('Eigener <b>Text</b>');
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('Eigener &lt;b&gt;Text&lt;/b&gt;', $html);
self::assertStringNotContainsString('Alle Fähigkeiten sind nutzbar.', $html);
}
public function testANonStringExplanationFromTheFilterKeepsTheGenericText(): void
{
Filters\expectApplied(CompatibilityTab::EXPLANATION_FILTER)->once()->andReturn(['x']);
self::assertStringContainsString('Alle Fähigkeiten sind nutzbar.', self::html($this->tab(self::profile())));
}
/** @return array{heading: string, cell: Closure} */
private static function column(string $heading, Closure $cell): array
{
return ['heading' => $heading, 'cell' => $cell];
}
public function testInvalidColumnsAreDroppedAndTheRestIsReindexed(): void
{
Filters\expectApplied(CompatibilityTab::COLUMNS_FILTER)->once()->andReturn([
3 => self::column('Gut', static fn (): array => ['ok']),
5 => ['heading' => 42, 'cell' => static fn (): array => ['x']],
6 => ['heading' => 'Ohne Closure', 'cell' => 'strlen'],
7 => 'kaputt',
9 => ['heading' => 'Ohne Zelle'],
]);
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('<th scope="col">Gut</th>', $html);
self::assertSame(3, substr_count(self::between($html, 'lit-mcp-compat-features', '</thead>'), '<th scope="col">'));
self::assertStringContainsString('<td data-label="Gut"><ul><li>ok</li></ul></td>', $html);
}
public function testAThrowingCellShowsADashLogsTheFailureAndKeepsTheTableClosed(): void
{
Filters\expectApplied(CompatibilityTab::COLUMNS_FILTER)->once()->andReturn([
self::column('Kaputt', static function (): array {
throw new \RuntimeException('Zelle <kaputt>');
}),
self::column('Heil', static fn (): array => ['ok']),
]);
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$html = self::html($this->tab(self::profile()));
} finally {
ini_set('error_log', (string) $previous);
}
$logged = (string) file_get_contents($log);
unlink($log);
self::assertStringContainsString('<td data-label="Kaputt">–</td>', $html);
self::assertStringContainsString('<td data-label="Heil"><ul><li>ok</li></ul></td>', $html);
self::assertStringContainsString('</tbody></table><h3>Versionen</h3>', $html);
self::assertStringContainsString('fake capability column "Kaputt" failed', $logged);
self::assertStringContainsString('Zelle <kaputt>', $logged);
}
public function testACellThatReturnsNoListShowsADash(): void
{
Filters\expectApplied(CompatibilityTab::COLUMNS_FILTER)->once()->andReturn([
self::column('Text', static fn (): string => 'kein Array'),
self::column('Karte', static fn (): array => ['a' => 'x']),
self::column('Zahlen', static fn (): array => [1, 2]),
]);
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('<td data-label="Text">–</td>', $html);
self::assertStringContainsString('<td data-label="Karte">–</td>', $html);
self::assertStringContainsString('<td data-label="Zahlen">–</td>', $html);
}
public function testANonArrayColumnsFilterResultMeansNoExtraColumns(): void
{
Filters\expectApplied(CompatibilityTab::COLUMNS_FILTER)->once()->andReturn('kaputt');
self::assertSame(
2,
substr_count(self::between(self::html($this->tab(self::profile())), 'lit-mcp-compat-features', '</thead>'), '<th scope="col">')
);
}
public function testEverythingFromThirdPartiesIsEscaped(): void
{
$profile = self::profile(
Tier::ReleasePerVersion,
self::matrix(ReleaseState::Incompatible, ['<script>alert(1)</script>']),
version: '<b>x</b>',
tested: ['<i>t</i>']
);
$html = self::html($this->tab($profile, self::report(false, '<b>x</b>')));
self::assertStringNotContainsString('<script>', $html);
self::assertStringNotContainsString('<b>x</b>', $html);
self::assertStringNotContainsString('<i>t</i>', $html);
self::assertStringNotContainsString('<i>x</i>', $html);
self::assertStringNotContainsString('<b>Label</b>', $html);
self::assertStringContainsString('&lt;b&gt;x&lt;/b&gt;', $html);
self::assertStringContainsString('&lt;script&gt;alert(1)&lt;/script&gt;', $html);
}
// --- 1.21: forms and actions -------------------------------------------------------------------------------
private function actionsDie(): void
{
Functions\when('wp_die')->alias(static function (): never {
throw new \RuntimeException('died');
});
}
private static function entry(string $version = '1.2.0'): array
{
return [
'version' => $version,
'plugin_version' => Plugin::VERSION,
'user_id' => 1,
'released_at' => '2026-10-01T08:00:00+00:00',
'report_id' => 'r1',
];
}
/** A store with the given entries for the installed state; add()/withdraw() expectations are the test's. */
private static function store(?array $entry = null): ReleaseStore
{
$releases = Mockery::mock(ReleaseStore::class);
$releases->allows('entries')->andReturn($entry === null ? [] : [$entry]);
$releases->allows('find')->andReturn($entry);
return $releases;
}
public function testRegistersTheAdminPostHandlers(): void
{
$tab = $this->tab(self::profile());
Actions\expectAdded('admin_post_lit_mcp_compat_release')->once()->with([$tab, 'release']);
Actions\expectAdded('admin_post_lit_mcp_compat_withdraw')->once()->with([$tab, 'withdraw']);
$tab->registerActions();
}
public function testTheCheckFormPostsToTheRestRouteWithTheRestNonce(): void
{
$html = self::html($this->tab(self::profile()));
self::assertStringContainsString('action="https://k.de/wp-json/lit-mcp/v1/fake-check"', $html);
self::assertStringContainsString('name="_wpnonce" value="nonce-wp_rest"', $html);
self::assertStringContainsString('Prüfung starten', $html);
}
public function testReleaseNeedsAFreshGreenReportForTheInstalledState(): void
{
$_POST['profile'] = 'fake';
$reports = [null, self::report(false), self::report(true, '1.1.0', 'key1.1.0'), self::report(true, '1.2.0', 'old')];
foreach ($reports as $report) {
$this->redirect = null;
$releases = self::store();
$releases->expects('add')->never();
Actions\expectDone('lit_mcp_compat_changed')->never();
$this->tab(self::profile(), $report, $releases)->release();
self::assertStringContainsString('lit_mcp_notice=compat_release_refused', (string) $this->redirect);
self::assertStringContainsString('lit_mcp_profile=fake', (string) $this->redirect);
}
}
public function testReleaseStoresTheEntryLogsItAndAnnouncesTheChange(): void
{
$_POST['profile'] = 'fake';
$releases = self::store();
$releases->expects('add')->once()->with('1.2.0', Plugin::VERSION, 1, 'r1');
$log = new FakeChangeLog();
Actions\expectDone('lit_mcp_compat_changed')->once()->with('fake');
$this->tab(self::profile(), self::report(), $releases, $log)->release();
self::assertSame(['lit_mcp_compat_release_fake'], $this->nonces);
self::assertStringContainsString('tab=compatibility', (string) $this->redirect);
self::assertStringContainsString('lit_mcp_notice=compat_released', (string) $this->redirect);
self::assertSame('lit/fake-release', $log->records[0]['ability']);
self::assertSame('fake', $log->records[0]['object_type']);
self::assertSame('1.2.0', $log->records[0]['object_id']);
self::assertStringContainsString('Fake <Plugin> 1.2.0', $log->records[0]['summary']);
}
/** @dataProvider refusedReleases */
public function testReleaseIsRefusedWhereTheTabOffersNone(Profile $profile, ?array $entry): void
{
$_POST['profile'] = 'fake';
$releases = self::store($entry);
$releases->expects('add')->never();
Actions\expectDone('lit_mcp_compat_changed')->never();
$this->tab($profile, self::report(), $releases)->release();
self::assertStringContainsString('lit_mcp_notice=compat_release_refused', (string) $this->redirect);
}
/** @return array<string, array{Profile, ?array}> */
public static function refusedReleases(): array
{
return [
'version tested in the plugin' => [self::profile(tested: ['1.2.0']), null],
'already released' => [self::profile(), self::entry()],
'report only' => [self::profile(Tier::ReportOnly), null],
'inactive' => [self::profile(active: false), null],
'version unknown' => [self::profile(version: null), null],
'per version, but already released' => [
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released)),
null,
],
'per version, but incompatible' => [
self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Incompatible, ['x'])),
null,
],
];
}
public function testAReleasePerVersionProfileIsReleasedWhileUntested(): void
{
$_POST['profile'] = 'fake';
$releases = self::store();
$releases->expects('add')->once();
Actions\expectDone('lit_mcp_compat_changed')->once();
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$this->tab($profile, self::report(), $releases, new FakeChangeLog())->release();
self::assertStringContainsString('lit_mcp_notice=compat_released', (string) $this->redirect);
}
public function testAnUnknownProfileIsRefusedBeforeTheNonceOrAnyStore(): void
{
$this->actionsDie();
Actions\expectDone('lit_mcp_compat_changed')->never();
$releases = self::store();
$releases->expects('add')->never();
$releases->expects('withdraw')->never();
foreach (['nope', '', "fake' OR 1=1"] as $id) {
$_POST['profile'] = $id;
foreach (['release', 'withdraw'] as $method) {
try {
$this->tab(self::profile(), self::report(), $releases)->$method();
self::fail('expected the request to stop');
} catch (\RuntimeException $e) {
self::assertSame('died', $e->getMessage());
}
}
}
self::assertNull($this->redirect);
self::assertSame([], $this->nonces);
}
public function testAMissingProfileFieldIsRefused(): void
{
$this->actionsDie();
unset($_POST['profile']);
$this->expectException(\RuntimeException::class);
$this->tab(self::profile())->release();
}
public function testActionsNeedManageOptionsAndTheNonceOfTheProfile(): void
{
$_POST['profile'] = 'fake';
$this->actionsDie();
Functions\when('current_user_can')->justReturn(false);
$releases = self::store();
$releases->expects('add')->never();
$releases->expects('withdraw')->never();
foreach (['release', 'withdraw'] as $method) {
try {
$this->tab(self::profile(), self::report(), $releases)->$method();
self::fail('expected 403');
} catch (\RuntimeException $e) {
self::assertSame('died', $e->getMessage());
}
}
// With the right but a failing nonce check (wrong nonce: WordPress dies) nothing is stored either.
Functions\when('current_user_can')->justReturn(true);
Functions\when('check_admin_referer')->alias(static function (): never {
throw new \RuntimeException('bad nonce');
});
foreach (['release', 'withdraw'] as $method) {
try {
$this->tab(self::profile(), self::report(), $releases)->$method();
self::fail('expected the nonce to stop');
} catch (\RuntimeException $e) {
self::assertSame('bad nonce', $e->getMessage());
}
}
}
public function testWithdrawRemovesTheEntryOfTheInstalledVersionLogsItAndAnnouncesTheChange(): void
{
$_POST['profile'] = 'fake';
$releases = self::store(self::entry());
$releases->expects('withdraw')->once()->with('1.2.0', Plugin::VERSION)->andReturn(true);
$log = new FakeChangeLog();
Actions\expectDone('lit_mcp_compat_changed')->once()->with('fake');
$this->tab(self::profile(), null, $releases, $log)->withdraw();
self::assertSame(['lit_mcp_compat_withdraw_fake'], $this->nonces);
self::assertStringContainsString('lit_mcp_notice=compat_withdrawn', (string) $this->redirect);
self::assertStringContainsString('lit_mcp_profile=fake', (string) $this->redirect);
self::assertSame('lit/fake-release', $log->records[0]['ability']);
}
public function testWithdrawWithoutAnEntryFails(): void
{
$_POST['profile'] = 'fake';
$releases = self::store();
$releases->expects('withdraw')->once()->andReturn(false);
Actions\expectDone('lit_mcp_compat_changed')->never();
$this->tab(self::profile(), null, $releases)->withdraw();
self::assertStringContainsString('lit_mcp_notice=compat_withdraw_failed', (string) $this->redirect);
}
public function testUntestedWithAFreshGreenReportOffersTheRelease(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
$html = self::html($this->tab($profile, self::report()));
self::assertStringContainsString('name="action" value="lit_mcp_compat_release"', $html);
self::assertStringContainsString('name="profile" value="fake"', $html);
self::assertStringContainsString('value="nonce-lit_mcp_compat_release_fake"', $html);
self::assertStringContainsString('1.2.0 freigeben', $html);
self::assertStringNotContainsString('lit_mcp_compat_withdraw', $html);
}
public function testUntestedWithoutAFreshReportAsksForACheckInsteadOfOfferingTheRelease(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Untested));
foreach ([null, self::report(false)] as $report) {
$html = self::html($this->tab($profile, $report));
self::assertStringNotContainsString('lit_mcp_compat_release', $html);
self::assertStringContainsString('Freigeben geht erst nach einer vollständig grünen Prüfung', $html);
}
}
public function testAReleasedPerVersionProfileOffersNeitherReleaseNorWithdrawal(): void
{
$profile = self::profile(Tier::ReleasePerVersion, self::matrix(ReleaseState::Released));
$html = self::html($this->tab($profile, self::report()));
self::assertStringNotContainsString('lit_mcp_compat_release', $html);
self::assertStringNotContainsString('lit_mcp_compat_withdraw', $html);
}
public function testLockOnFailureOffersTheReleaseForAVersionNotTestedInThePlugin(): void
{
$html = self::html($this->tab(self::profile(), self::report()));
self::assertStringContainsString('value="lit_mcp_compat_release"', $html);
self::assertStringNotContainsString('lit_mcp_compat_withdraw', $html);
}
public function testATestedVersionAndReportOnlyOfferNoRelease(): void
{
foreach ([self::profile(tested: ['1.2.0']), self::profile(Tier::ReportOnly)] as $profile) {
$html = self::html($this->tab($profile, self::report()));
self::assertStringNotContainsString('lit_mcp_compat_release', $html);
self::assertStringNotContainsString('Freigeben geht erst', $html);
}
}
public function testAnExistingReleaseCanBeWithdrawnWhateverTheState(): void
{
foreach ([ReleaseState::Released, ReleaseState::Incompatible] as $state) {
$profile = self::profile(Tier::ReleasePerVersion, self::matrix($state, $state === ReleaseState::Released ? [] : ['x']));
$html = self::html($this->tab($profile, self::report(), self::store(self::entry())));
self::assertStringContainsString('value="lit_mcp_compat_withdraw"', $html);
self::assertStringContainsString('value="nonce-lit_mcp_compat_withdraw_fake"', $html);
self::assertStringNotContainsString('value="lit_mcp_compat_release"', $html);
}
}
}