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>
321 lines
13 KiB
PHP
321 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
|
||
|
||
use Brain\Monkey\Filters;
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Compatibility\ReleaseState;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Compatibility\ManifestCheck;
|
||
use LIT\MCP\Compatibility\Problem;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||
use LIT\MCP\Compatibility\ReleaseStore;
|
||
use LIT\MCP\Plugin;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ReleaseResolverTest extends TestCase
|
||
{
|
||
private const UNKNOWN = '5.99.0';
|
||
|
||
/** @var array<string, mixed> */
|
||
private array $transients = [];
|
||
|
||
private int $mirrorRuns = 0;
|
||
|
||
private int $codeRuns = 0;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->transients = [];
|
||
$this->mirrorRuns = 0;
|
||
$this->codeRuns = 0;
|
||
Functions\when('get_transient')->alias(fn (string $key): mixed => $this->transients[$key] ?? false);
|
||
Functions\when('set_transient')->alias(function (string $key, mixed $value): bool {
|
||
$this->transients[$key] = $value;
|
||
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @param list<Problem> $code
|
||
* @param list<Problem> $mirrors
|
||
* @param array<string, mixed>|null $entry
|
||
*/
|
||
private function resolver(
|
||
string $version,
|
||
array $code = [],
|
||
array $mirrors = [],
|
||
?array $entry = null
|
||
): ReleaseResolver {
|
||
$check = Mockery::mock(ManifestCheck::class);
|
||
$check->allows('codeProblems')->andReturnUsing(function () use ($code): array {
|
||
++$this->codeRuns;
|
||
|
||
return $code;
|
||
});
|
||
$check->allows('mirrorProblems')->andReturnUsing(function () use ($mirrors): array {
|
||
++$this->mirrorRuns;
|
||
|
||
return $mirrors;
|
||
});
|
||
$store = Mockery::mock(ReleaseStore::class);
|
||
$store->allows('find')->with($version, Plugin::VERSION)->andReturn($entry);
|
||
|
||
return new ReleaseResolver(
|
||
$check,
|
||
$store,
|
||
static fn (): ?string => $version,
|
||
static fn (string $v): string => 'key-' . $v
|
||
);
|
||
}
|
||
|
||
/** @param list<DiviFeature> $features */
|
||
private static function problem(string $kind, array $features, string $label = 'Seiten: et_x()'): Problem
|
||
{
|
||
return new Problem($label, $features, $kind);
|
||
}
|
||
|
||
/** @return array<string, string> Feature value => state, for every feature. */
|
||
private static function states(ReleaseStatus $status): array
|
||
{
|
||
$states = [];
|
||
foreach (DiviFeature::cases() as $feature) {
|
||
$states[$feature->value] = $status->matrix->of($feature)->state->value;
|
||
}
|
||
|
||
return $states;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, string> $except Feature value => state.
|
||
* @return array<string, string>
|
||
*/
|
||
private static function allBut(string $state, array $except = []): array
|
||
{
|
||
$states = [];
|
||
foreach (DiviFeature::cases() as $feature) {
|
||
$states[$feature->value] = $except[$feature->value] ?? $state;
|
||
}
|
||
|
||
return $states;
|
||
}
|
||
|
||
public function testATestedVersionWithoutProblemsIsReleasedFromCode(): void
|
||
{
|
||
$status = $this->resolver('5.2.1')->resolve();
|
||
|
||
self::assertSame(ReleaseState::Released, $status->state);
|
||
self::assertSame('code', $status->source);
|
||
self::assertSame(self::allBut('released'), self::states($status));
|
||
self::assertSame('code', $status->matrix->of(DiviFeature::PageWrite)->source);
|
||
}
|
||
|
||
public function testAReleaseCoversChangedSignaturesAndMissingReferences(): void
|
||
{
|
||
$status = $this->resolver('5.2.1', [
|
||
self::problem(Problem::SIGNATURE, [DiviFeature::PageWrite]),
|
||
self::problem(Problem::REFERENCE_MISSING, [DiviFeature::Catalog]),
|
||
])->resolve();
|
||
|
||
self::assertSame(self::allBut('released'), self::states($status));
|
||
self::assertSame([], $status->matrix->unusable());
|
||
self::assertSame([], $status->problems);
|
||
}
|
||
|
||
public function testAHardProblemLocksItsFeaturesEvenForATestedVersion(): void
|
||
{
|
||
$status = $this->resolver('5.2.1', [
|
||
self::problem(Problem::CALL, [DiviFeature::PageWrite], 'Seiten: et_x()'),
|
||
self::problem(Problem::CONSTANT, [DiviFeature::Catalog], 'Katalog: Konstante X'),
|
||
])->resolve();
|
||
|
||
self::assertSame(
|
||
self::allBut('released', ['page-write' => 'incompatible', 'catalog' => 'incompatible']),
|
||
self::states($status)
|
||
);
|
||
self::assertSame(['Seiten: et_x()'], $status->matrix->of(DiviFeature::PageWrite)->problems);
|
||
self::assertSame(ReleaseState::Incompatible, $status->state);
|
||
self::assertSame(['Seiten: et_x()', 'Katalog: Konstante X'], $status->problems);
|
||
self::assertFalse($status->matrix->of(DiviFeature::PageWrite)->usable());
|
||
}
|
||
|
||
public function testAProblemWithoutFeaturesLocksEveryFeature(): void
|
||
{
|
||
$status = $this->resolver('5.2.1', [self::problem(Problem::CALL, [])])->resolve();
|
||
|
||
self::assertSame(self::allBut('incompatible'), self::states($status));
|
||
}
|
||
|
||
/** Review focus 2: a changed mirror locks exactly its features – released version or automatic release. */
|
||
public function testAChangedMirrorLocksExactlyItsFeatures(): void
|
||
{
|
||
$mirror = self::problem(
|
||
Problem::MIRROR,
|
||
[DiviFeature::ThemeBuilderWrite, DiviFeature::LibraryWrite],
|
||
'Theme Builder: Nachbau von y() – z'
|
||
);
|
||
$expected = ['tb-write' => 'incompatible', 'library-write' => 'incompatible'];
|
||
|
||
self::assertSame(
|
||
self::allBut('released', $expected),
|
||
self::states($this->resolver('5.2.1', mirrors: [$mirror])->resolve())
|
||
);
|
||
$automatic = $this->resolver(self::UNKNOWN, mirrors: [$mirror])->resolve();
|
||
self::assertSame(self::allBut('released', $expected), self::states($automatic));
|
||
self::assertSame(
|
||
['Theme Builder: Nachbau von y() – z'],
|
||
$automatic->matrix->of(DiviFeature::ThemeBuilderWrite)->problems
|
||
);
|
||
}
|
||
|
||
public function testAnUnknownVersionWithoutProblemsIsReleasedAutomatically(): void
|
||
{
|
||
$status = $this->resolver(self::UNKNOWN)->resolve();
|
||
|
||
self::assertSame(self::allBut('released'), self::states($status));
|
||
self::assertSame('auto', $status->source);
|
||
self::assertSame('auto', $status->matrix->of(DiviFeature::PresetWrite)->source);
|
||
self::assertSame([], $status->matrix->unusable());
|
||
}
|
||
|
||
/** Review focus 3: the automatic release needs every signature unchanged. */
|
||
public function testAChangedSignatureMakesItsFeatureIncompatibleUnderAutomaticRelease(): void
|
||
{
|
||
$status = $this->resolver(self::UNKNOWN, [
|
||
self::problem(Problem::SIGNATURE, [DiviFeature::PresetWrite], 'Designsystem: x() – Signatur geändert'),
|
||
])->resolve();
|
||
|
||
self::assertSame(self::allBut('released', ['preset-write' => 'incompatible']), self::states($status));
|
||
self::assertSame(
|
||
['Designsystem: x() – Signatur geändert'],
|
||
$status->matrix->of(DiviFeature::PresetWrite)->problems
|
||
);
|
||
self::assertFalse($status->matrix->of(DiviFeature::PresetWrite)->usable());
|
||
}
|
||
|
||
public function testAMissingReferenceMakesItsFeatureUntestedUnderAutomaticRelease(): void
|
||
{
|
||
$status = $this->resolver(self::UNKNOWN, [
|
||
self::problem(Problem::REFERENCE_MISSING, [DiviFeature::Roles], 'Seiten: r() – Referenz fehlt'),
|
||
self::problem(Problem::REFERENCE_MISSING, [DiviFeature::PageRead]),
|
||
self::problem(Problem::SIGNATURE, [DiviFeature::PageRead]),
|
||
])->resolve();
|
||
|
||
self::assertSame(
|
||
self::allBut('released', ['roles' => 'untested', 'page-read' => 'incompatible']),
|
||
self::states($status)
|
||
);
|
||
self::assertSame(
|
||
['Seiten: r() – Referenz fehlt'],
|
||
$status->matrix->of(DiviFeature::Roles)->problems
|
||
);
|
||
}
|
||
|
||
/** Review focus 1: unknown version with the automatic release switched off. */
|
||
public function testWithoutAutomaticReleaseAnUnknownVersionIsUntestedUnlessSomethingHardFailed(): void
|
||
{
|
||
Filters\expectApplied(ReleaseResolver::AUTO_RELEASE_FILTER)->once()->with(true)->andReturn(false);
|
||
|
||
$status = $this->resolver(self::UNKNOWN, [
|
||
self::problem(Problem::SIGNATURE, [DiviFeature::PresetWrite]),
|
||
self::problem(Problem::CALL, [DiviFeature::Catalog]),
|
||
])->resolve();
|
||
|
||
self::assertSame(self::allBut('untested', ['catalog' => 'incompatible']), self::states($status));
|
||
self::assertNull($status->matrix->of(DiviFeature::PageRead)->source);
|
||
}
|
||
|
||
public function testOnlyALiteralTrueSwitchesTheAutomaticReleaseOn(): void
|
||
{
|
||
Filters\expectApplied(ReleaseResolver::AUTO_RELEASE_FILTER)->once()->andReturn(1);
|
||
|
||
self::assertSame(self::allBut('untested'), self::states($this->resolver(self::UNKNOWN)->resolve()));
|
||
}
|
||
|
||
public function testADatabaseReleaseReleasesEveryFeatureWithoutHardProblems(): void
|
||
{
|
||
$entry = ['user_id' => 1, 'released_at' => 'd'];
|
||
$status = $this->resolver(self::UNKNOWN, [
|
||
self::problem(Problem::SIGNATURE, [DiviFeature::PageWrite]),
|
||
self::problem(Problem::REFERENCE_MISSING, [DiviFeature::Catalog]),
|
||
], [self::problem(Problem::MIRROR, [DiviFeature::LibraryWrite])], $entry)->resolve();
|
||
|
||
self::assertSame(self::allBut('released', ['library-write' => 'incompatible']), self::states($status));
|
||
self::assertSame('database', $status->matrix->of(DiviFeature::PageWrite)->source);
|
||
self::assertSame($entry, $status->release);
|
||
}
|
||
|
||
public function testWithoutDiviNothingIsReleasedAutomatically(): void
|
||
{
|
||
$status = $this->resolver('')->resolve();
|
||
|
||
self::assertSame(ReleaseState::Untested, $status->state);
|
||
self::assertSame(self::allBut('untested'), self::states($status));
|
||
}
|
||
|
||
public function testCodeProblemsRunOnEveryResolveMirrorProblemsOncePerStateKey(): void
|
||
{
|
||
$mirrors = [self::problem(Problem::MIRROR, [DiviFeature::ThemeBuilderWrite], 'Theme Builder: Nachbau von y()')];
|
||
|
||
$first = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
|
||
$second = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
|
||
|
||
self::assertSame(2, $this->codeRuns, 'code and signature checks are live');
|
||
self::assertSame(1, $this->mirrorRuns, 'the second resolve reads the transient');
|
||
self::assertSame(self::states($first), self::states($second));
|
||
self::assertSame($first->problems, $second->problems);
|
||
self::assertSame(
|
||
['format' => 2, 'key' => 'key-5.2.1', 'problems' => [
|
||
['label' => 'Theme Builder: Nachbau von y()', 'kind' => 'mirror', 'features' => ['tb-write']],
|
||
]],
|
||
$this->transients[ReleaseResolver::CACHE]
|
||
);
|
||
|
||
$this->resolver('5.2.2', mirrors: $mirrors)->resolve();
|
||
self::assertSame(2, $this->mirrorRuns, 'another state key checks again');
|
||
}
|
||
|
||
/** Review focus 1: an old or broken transient is never trusted. */
|
||
public function testAnOldOrBrokenCacheEntryIsAMiss(): void
|
||
{
|
||
$mirrors = [self::problem(Problem::MIRROR, [DiviFeature::PageWrite])];
|
||
$entries = [
|
||
'format 1' => ['key' => 'key-5.2.1', 'problems' => []],
|
||
'other format' => ['format' => 3, 'key' => 'key-5.2.1', 'problems' => []],
|
||
'unknown feature' => ['format' => 2, 'key' => 'key-5.2.1', 'problems' => [
|
||
['label' => 'x', 'kind' => 'mirror', 'features' => ['page-delete']],
|
||
]],
|
||
'no list' => ['format' => 2, 'key' => 'key-5.2.1', 'problems' => 'none'],
|
||
'garbage' => 'x',
|
||
];
|
||
|
||
foreach ($entries as $case => $entry) {
|
||
$this->transients[ReleaseResolver::CACHE] = $entry;
|
||
$runs = $this->mirrorRuns;
|
||
|
||
$status = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
|
||
|
||
self::assertSame($runs + 1, $this->mirrorRuns, $case);
|
||
self::assertFalse($status->matrix->of(DiviFeature::PageWrite)->usable(), $case);
|
||
}
|
||
}
|
||
|
||
public function testTheCacheHoldsProblemsSoAReleaseCountsAtOnce(): void
|
||
{
|
||
Filters\expectApplied(ReleaseResolver::AUTO_RELEASE_FILTER)->andReturn(false);
|
||
|
||
$before = $this->resolver(self::UNKNOWN)->resolve();
|
||
$after = $this->resolver(self::UNKNOWN, entry: ['user_id' => 1, 'released_at' => 'd'])->resolve();
|
||
|
||
self::assertSame(ReleaseState::Untested, $before->state);
|
||
self::assertSame(ReleaseState::Released, $after->state);
|
||
self::assertSame(1, $this->mirrorRuns);
|
||
}
|
||
}
|