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:
co-authored by
Claude Sonnet 5.5
parent
5bb25e1e8f
commit
a7a385d3b3
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global variables in Divi's stored format (formats §2): type => id => {id, label, value, order, status,
|
||||||
|
* lastUpdated, variableType}. Takes the stored shape and the shape of GlobalData::get_global_variables() alike:
|
||||||
|
* groups and entries may be objects, "allowedActions", the customizer fonts (--et_global_*_font), the group "colors"
|
||||||
|
* and empty groups are dropped. The type "gradients" (Divi 5.15) is kept unchanged (ruling V6). Variables are never
|
||||||
|
* removed, only archived (ruling V1): restoredFrom() keeps every current variable. Immutable. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class VariableBook
|
||||||
|
{
|
||||||
|
/** Types in the order of Divi's stored data; VariableValue::TYPES plus the pass-through "gradients". */
|
||||||
|
private const TYPES = ['numbers', 'strings', 'images', 'links', 'fonts', 'gradients'];
|
||||||
|
|
||||||
|
private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
/** @param array<string, array<string, array<string, mixed>>> $data */
|
||||||
|
private function __construct(private array $data)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<array-key, mixed>|object $raw Stored data or get_global_variables(). */
|
||||||
|
public static function fromDivi(array|object $raw): self
|
||||||
|
{
|
||||||
|
$raw = self::plain($raw);
|
||||||
|
$data = [];
|
||||||
|
foreach (self::TYPES as $type) {
|
||||||
|
foreach (is_array($raw[$type] ?? null) ? $raw[$type] : [] as $id => $entry) {
|
||||||
|
// Customizer fonts have IDs like "--et_global_body_font"; Divi stores only "gvid-" variables.
|
||||||
|
if (is_array($entry) && str_starts_with((string) $id, 'gvid-')) {
|
||||||
|
$data[$type][(string) $id] = self::normalize($type, (string) $id, $entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<string, array<string, mixed>>> */
|
||||||
|
public function data(): array
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rev(): string
|
||||||
|
{
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
|
||||||
|
$json = (string) json_encode(self::sorted($this->data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
return Rev::of('variables' . "\0" . $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
public function entry(string $type, string $id): ?array
|
||||||
|
{
|
||||||
|
return $this->data[$type][$id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function typeOf(string $id): ?string
|
||||||
|
{
|
||||||
|
foreach ($this->data as $type => $entries) {
|
||||||
|
if (isset($entries[$id])) {
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The variable $needle names: an ID, else the one variable of that label (case-insensitive, trimmed) –
|
||||||
|
* optionally only within $type.
|
||||||
|
*
|
||||||
|
* @return array{type: string, id: string}|null
|
||||||
|
* @throws InvalidArgumentException When several variables share the label (ruling S13a).
|
||||||
|
*/
|
||||||
|
public function find(string $needle, ?string $type = null): ?array
|
||||||
|
{
|
||||||
|
$key = mb_strtolower(trim($needle));
|
||||||
|
$hits = [];
|
||||||
|
foreach ($this->data as $kind => $entries) {
|
||||||
|
if ($type !== null && $kind !== $type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($entries[$needle])) {
|
||||||
|
return ['type' => $kind, 'id' => $needle];
|
||||||
|
}
|
||||||
|
foreach ($entries as $id => $entry) {
|
||||||
|
if ($key !== '' && mb_strtolower(trim((string) $entry['label'])) === $key) {
|
||||||
|
$hits[] = ['type' => $kind, 'id' => (string) $id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($hits) > 1) {
|
||||||
|
throw new InvalidArgumentException(sprintf(
|
||||||
|
'Name der globalen Variable "%s" ist mehrdeutig; bitte die ID angeben: %s.',
|
||||||
|
trim($needle),
|
||||||
|
implode(', ', array_column($hits, 'id'))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hits[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry Added or replaced; missing keys are filled, id and variableType are set.
|
||||||
|
* @throws InvalidArgumentException For a type Divi does not store.
|
||||||
|
*/
|
||||||
|
public function with(string $type, string $id, array $entry): self
|
||||||
|
{
|
||||||
|
if (! in_array($type, self::TYPES, true)) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Unbekannter Variablentyp "%s".', $type));
|
||||||
|
}
|
||||||
|
$data = $this->data;
|
||||||
|
$data[$type][$id] = self::normalize($type, $id, $entry);
|
||||||
|
|
||||||
|
return new self(self::inTypeOrder($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The order for a variable appended to $type: the highest numeric order there plus one, as a string. */
|
||||||
|
public function nextOrder(string $type): string
|
||||||
|
{
|
||||||
|
$highest = 0;
|
||||||
|
foreach ($this->data[$type] ?? [] as $entry) {
|
||||||
|
if (is_numeric($entry['order'])) {
|
||||||
|
$highest = max($highest, (int) $entry['order']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ($highest + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inventory a restore writes (ruling V1b): every variable of $snapshot, and every variable that exists only
|
||||||
|
* here stays – archived, with order "" (Divi refuses a save that drops a stored ID).
|
||||||
|
*/
|
||||||
|
public function restoredFrom(self $snapshot): self
|
||||||
|
{
|
||||||
|
$data = $snapshot->data;
|
||||||
|
foreach ($this->data as $type => $entries) {
|
||||||
|
foreach ($entries as $id => $entry) {
|
||||||
|
if (! isset($snapshot->data[$type][$id])) {
|
||||||
|
$data[$type][$id] = array_merge($entry, ['status' => 'archived', 'order' => '']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(self::inTypeOrder($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The variables restoredFrom() would archive: active ones that are not in $snapshot (already archived ones stay
|
||||||
|
* archived and need no warning).
|
||||||
|
*
|
||||||
|
* @return list<array{type: string, id: string, label: string}>
|
||||||
|
*/
|
||||||
|
public function onlyHere(self $snapshot): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
foreach ($this->data as $type => $entries) {
|
||||||
|
foreach ($entries as $id => $entry) {
|
||||||
|
if (! isset($snapshot->data[$type][$id]) && $entry['status'] !== 'archived') {
|
||||||
|
$rows[] = ['type' => $type, 'id' => (string) $id, 'label' => (string) $entry['label']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new variable ID like the Visual Builder's: "gvid-" and 10 characters of [0-9a-z] (formats §2). */
|
||||||
|
public static function newId(?Closure $random = null): string
|
||||||
|
{
|
||||||
|
$random ??= static function (int $length): string {
|
||||||
|
$id = '';
|
||||||
|
for ($i = 0; $i < $length; ++$i) {
|
||||||
|
$id .= self::ALPHABET[random_int(0, 35)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
};
|
||||||
|
|
||||||
|
return 'gvid-' . $random(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** lastUpdated of a variable written now (same format as a color's). */
|
||||||
|
public static function now(): string
|
||||||
|
{
|
||||||
|
return ColorBook::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array-key, mixed> $entry
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function normalize(string $type, string $id, array $entry): array
|
||||||
|
{
|
||||||
|
$text = static fn (string $key, string $default): string => is_scalar($entry[$key] ?? null)
|
||||||
|
? (string) $entry[$key]
|
||||||
|
: $default;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'label' => $text('label', ''),
|
||||||
|
'value' => $text('value', ''),
|
||||||
|
'order' => $text('order', ''),
|
||||||
|
'status' => $text('status', 'active'),
|
||||||
|
'lastUpdated' => $text('lastUpdated', ''),
|
||||||
|
'variableType' => $type,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, array<string, mixed>>> $data
|
||||||
|
* @return array<string, array<string, array<string, mixed>>>
|
||||||
|
*/
|
||||||
|
private static function inTypeOrder(array $data): array
|
||||||
|
{
|
||||||
|
$ordered = [];
|
||||||
|
foreach (self::TYPES as $type) {
|
||||||
|
if (isset($data[$type])) {
|
||||||
|
$ordered[$type] = $data[$type];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sorted(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
$value = array_map([self::class, 'sorted'], $value);
|
||||||
|
if (! array_is_list($value)) {
|
||||||
|
ksort($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function plain(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (is_object($value)) {
|
||||||
|
$value = get_object_vars($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($value) ? array_map([self::class, 'plain'], $value) : $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user