feat: VariableBook als reiner Variablenbestand mit Lese-/Speicherform, Archivierung statt Löschen und Restore-Vereinigung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:00:21 +02:00
co-authored by Claude Sonnet 5.5
parent 5bb25e1e8f
commit a7a385d3b3
2 changed files with 545 additions and 0 deletions
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\VariableBook;
use PHPUnit\Framework\TestCase;
use stdClass;
final class VariableBookTest extends TestCase
{
/** @return array<string, mixed> One stored variable (formats §2). */
public static function variable(
string $type,
string $id,
string $label,
string $value,
string $order = '1',
string $status = 'active'
): array {
return [
'id' => $id, 'label' => $label, 'value' => $value, 'order' => $order, 'status' => $status,
'lastUpdated' => '2026-09-27T22:21:39.000Z', 'variableType' => $type,
];
}
/** @return array<string, array<string, array<string, mixed>>> The stored shape (et_divi_global_variables). */
public static function stored(): array
{
return [
'numbers' => [
'gvid-f6yq6mayfy' => self::variable('numbers', 'gvid-f6yq6mayfy', 'Eckenradius', '8px', '1'),
'gvid-5dec72fwsc' => self::variable('numbers', 'gvid-5dec72fwsc', 'Abstand Karte', '2rem', '2'),
'gvid-old0000001' => self::variable('numbers', 'gvid-old0000001', 'Alt', '1px', '', 'archived'),
],
'strings' => [
'gvid-7uckqqhmrh' => self::variable('strings', 'gvid-7uckqqhmrh', 'Slogan', 'Handwerk', '1'),
'gvid-dup0000001' => self::variable('strings', 'gvid-dup0000001', 'Eckenradius', 'Text', '2'),
],
'fonts' => [
'gvid-d4a5bsz2gt' => self::variable('fonts', 'gvid-d4a5bsz2gt', 'Akzentschrift', 'Merriweather'),
],
'gradients' => [
'gvid-grad000001' => self::variable(
'gradients',
'gvid-grad000001',
'Verlauf',
'linear-gradient(red, blue)'
),
],
];
}
/** @return array<string, mixed> The shape of GlobalData::get_global_variables() (formats §2, verified in DDEV). */
public static function fromGetter(): array
{
$actions = ['editLabel' => true, 'editValue' => true, 'reorder' => true, 'remove' => true];
$stored = self::stored();
$withActions = static fn (array $group): array => array_map(
static fn (array $entry): array => $entry + ['allowedActions' => $actions],
$group
);
return [
'numbers' => (object) $withActions($stored['numbers']),
'strings' => (object) $withActions($stored['strings']),
'images' => new stdClass(),
'links' => new stdClass(),
'colors' => (object) ['gcid-x1' => ['id' => 'gcid-x1', 'label' => 'Farbe', 'value' => '#fff']],
'fonts' => (object) ([
'--et_global_heading_font' => ['id' => '--et_global_heading_font', 'label' => 'Überschrift',
'value' => 'Open Sans', 'status' => 'active', 'allowedActions' => $actions],
'--et_global_body_font' => ['id' => '--et_global_body_font', 'label' => 'Fließtext',
'value' => 'Open Sans', 'status' => 'active', 'allowedActions' => $actions],
] + $withActions($stored['fonts'])),
'gradients' => (object) $withActions($stored['gradients']),
];
}
public function testStoredShapeIsKeptAsItIs(): void
{
self::assertSame(self::stored(), VariableBook::fromDivi(self::stored())->data());
}
public function testGetterShapeEqualsTheStoredShape(): void
{
$fromGetter = VariableBook::fromDivi(self::fromGetter());
$fromStored = VariableBook::fromDivi(self::stored());
self::assertSame($fromStored->data(), $fromGetter->data());
self::assertSame($fromStored->rev(), $fromGetter->rev());
self::assertNull($fromGetter->typeOf('--et_global_body_font'));
self::assertNull($fromGetter->typeOf('gcid-x1'));
self::assertArrayNotHasKey('colors', $fromGetter->data());
self::assertArrayNotHasKey('images', $fromGetter->data(), 'empty groups are dropped');
self::assertArrayNotHasKey('allowedActions', $fromGetter->entry('numbers', 'gvid-f6yq6mayfy') ?? []);
}
public function testGradientsPassThroughUnchanged(): void
{
$book = VariableBook::fromDivi(self::stored());
self::assertSame('linear-gradient(red, blue)', $book->entry('gradients', 'gvid-grad000001')['value']);
self::assertSame('gradients', $book->typeOf('gvid-grad000001'));
self::assertSame(
'gradients',
$book->with('gradients', 'gvid-grad000001', ['label' => 'Verlauf 2', 'value' => 'x'])
->entry('gradients', 'gvid-grad000001')['variableType']
);
}
public function testMissingKeysAreFilledAndIdAndTypeComeFromTheSlot(): void
{
$book = VariableBook::fromDivi([
'numbers' => ['gvid-x1' => ['id' => 'other', 'label' => 'A', 'value' => 8, 'order' => null,
'variableType' => 'strings', 'extra' => 'dropped']],
'strings' => ['kein-gvid' => ['label' => 'B', 'value' => 'x']],
]);
self::assertSame([
'id' => 'gvid-x1', 'label' => 'A', 'value' => '8', 'order' => '', 'status' => 'active',
'lastUpdated' => '', 'variableType' => 'numbers',
], $book->entry('numbers', 'gvid-x1'));
self::assertArrayNotHasKey('strings', $book->data());
}
public function testRevIsStableAndChangesWithTheContent(): void
{
$book = VariableBook::fromDivi(self::stored());
$reversed = VariableBook::fromDivi(array_map(
static fn (array $group): array => array_reverse($group, true),
array_reverse(self::stored(), true)
));
self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev());
self::assertSame($book->rev(), $reversed->rev());
self::assertNotSame(
$book->rev(),
$book->with('numbers', 'gvid-f6yq6mayfy', ['label' => 'X', 'value' => '9px'])->rev()
);
}
public function testRealDiviFixtureAndItsGetterShapeAreTheSame(): void
{
$json = (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/stage4/global-variables.json');
$fixture = json_decode($json, true);
$stored = VariableBook::fromDivi($fixture['stored']);
$read = VariableBook::fromDivi($fixture['get_global_variables']);
self::assertSame($stored->rev(), $read->rev());
self::assertSame(['numbers', 'strings', 'images', 'links', 'fonts'], array_keys($read->data()));
self::assertSame('8px', $read->entry('numbers', 'gvid-f6yq6mayfy')['value']);
self::assertNull($read->typeOf('--et_global_heading_font'));
}
public function testFindByIdAndLabel(): void
{
$book = VariableBook::fromDivi(self::stored());
self::assertSame(['type' => 'numbers', 'id' => 'gvid-f6yq6mayfy'], $book->find('gvid-f6yq6mayfy'));
self::assertSame(['type' => 'strings', 'id' => 'gvid-7uckqqhmrh'], $book->find(' SLOGAN '));
self::assertSame(['type' => 'fonts', 'id' => 'gvid-d4a5bsz2gt'], $book->find('Akzentschrift', 'fonts'));
self::assertNull($book->find('Akzentschrift', 'numbers'));
self::assertNull($book->find('gvid-d4a5bsz2gt', 'numbers'));
self::assertNull($book->find('Fehlt'));
self::assertNull($book->find(''));
}
public function testAmbiguousLabelListsTheIdsAndAnArgumentTypeResolvesIt(): void
{
$book = VariableBook::fromDivi(self::stored());
self::assertSame(['type' => 'numbers', 'id' => 'gvid-f6yq6mayfy'], $book->find('Eckenradius', 'numbers'));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
'Name der globalen Variable "Eckenradius" ist mehrdeutig; bitte die ID angeben: '
. 'gvid-f6yq6mayfy, gvid-dup0000001.'
);
$book->find('Eckenradius');
}
public function testWithIsImmutableAndKeepsTheTypeOrder(): void
{
$book = VariableBook::fromDivi(self::stored());
$added = $book->with('images', 'gvid-img0000001', ['label' => 'Bild', 'value' => '12', 'order' => '1']);
self::assertCount(3, $book->data()['numbers']);
self::assertSame(['numbers', 'strings', 'images', 'fonts', 'gradients'], array_keys($added->data()));
self::assertSame('images', $added->entry('images', 'gvid-img0000001')['variableType']);
self::assertSame('active', $added->entry('images', 'gvid-img0000001')['status']);
self::assertNull($book->entry('images', 'gvid-img0000001'));
}
public function testWithRejectsTypesDiviDoesNotStore(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Unbekannter Variablentyp "colors".');
VariableBook::fromDivi([])->with('colors', 'gvid-x1', ['label' => 'A', 'value' => '#fff']);
}
public function testNextOrderIsTheHighestOrderPlusOne(): void
{
$book = VariableBook::fromDivi(self::stored());
self::assertSame('3', $book->nextOrder('numbers'), 'the archived entry with order "" does not count');
self::assertSame('2', $book->nextOrder('fonts'));
self::assertSame('1', $book->nextOrder('images'));
self::assertSame('1', $book->nextOrder('colors'));
self::assertSame('11', $book->with('links', 'gvid-l1', ['label' => 'L', 'value' => '/', 'order' => '10'])
->nextOrder('links'));
}
public function testRestoredFromKeepsCurrentOnlyVariablesArchived(): void
{
$current = VariableBook::fromDivi(self::stored())
->with('numbers', 'gvid-new0000001', ['label' => 'Neu', 'value' => '4px', 'order' => '3'])
->with('images', 'gvid-img0000001', ['label' => 'Bild', 'value' => '12', 'order' => '1'])
->with('strings', 'gvid-7uckqqhmrh', ['label' => 'Slogan neu', 'value' => 'Geändert', 'order' => '1']);
$snapshot = VariableBook::fromDivi(self::stored());
$restored = $current->restoredFrom($snapshot);
self::assertSame('Slogan', $restored->entry('strings', 'gvid-7uckqqhmrh')['label'], 'snapshot wins');
self::assertSame(
['label' => 'Neu', 'value' => '4px', 'order' => '', 'status' => 'archived'],
array_intersect_key(
$restored->entry('numbers', 'gvid-new0000001') ?? [],
array_flip(['status', 'order', 'label', 'value'])
)
);
self::assertSame('archived', $restored->entry('images', 'gvid-img0000001')['status']);
self::assertSame('', $restored->entry('images', 'gvid-img0000001')['order']);
self::assertSame('archived', $restored->entry('numbers', 'gvid-old0000001')['status']);
// Every ID of the current inventory survives, as Divi demands.
foreach ($current->data() as $type => $entries) {
foreach (array_keys($entries) as $id) {
self::assertNotNull($restored->entry($type, (string) $id), $id);
}
}
self::assertSame($snapshot->rev(), $snapshot->restoredFrom($snapshot)->rev());
}
public function testRestoredArchivedEntryLosesItsOrder(): void
{
$current = VariableBook::fromDivi(self::stored())
->with('fonts', 'gvid-gone000001', [
'label' => 'Weg', 'value' => 'Lato', 'order' => '7', 'status' => 'archived',
]);
$restored = $current->restoredFrom(VariableBook::fromDivi(self::stored()));
self::assertSame(['archived', ''], [
$restored->entry('fonts', 'gvid-gone000001')['status'],
$restored->entry('fonts', 'gvid-gone000001')['order'],
]);
}
public function testOnlyHereListsTheActiveVariablesARestoreWouldArchive(): void
{
$current = VariableBook::fromDivi(self::stored())
->with('numbers', 'gvid-new0000001', ['label' => 'Neu', 'value' => '4px'])
->with('fonts', 'gvid-gone000001', ['label' => 'Weg', 'value' => 'Lato', 'status' => 'archived']);
self::assertSame(
[['type' => 'numbers', 'id' => 'gvid-new0000001', 'label' => 'Neu']],
$current->onlyHere(VariableBook::fromDivi(self::stored()))
);
self::assertSame([], $current->onlyHere($current));
}
public function testNewIdHasTheBuilderFormat(): void
{
self::assertMatchesRegularExpression('/^gvid-[0-9a-z]{10}$/', VariableBook::newId());
self::assertNotSame(VariableBook::newId(), VariableBook::newId());
self::assertSame(
'gvid-zzzzzzzzzz',
VariableBook::newId(static fn (int $length): string => str_repeat('z', $length))
);
self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', VariableBook::now());
}
}