feat: ColorBook als reiner Farbbestand mit Auflösung nach ID/Alias/Label, Verweisen, Zyklusprüfung und Änderungsmenge

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 13:57:23 +02:00
co-authored by Claude Sonnet 5.5
parent fb46078d42
commit 2428801bc9
2 changed files with 509 additions and 0 deletions
@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Design\ColorBook;
use PHPUnit\Framework\TestCase;
use stdClass;
final class ColorBookTest extends TestCase
{
/** @return array<string, array<string, mixed>> The shape of GlobalData::get_global_colors() (formats §1). */
public static function stored(string $customizerStamp = '2026-10-19T08:00:00.000Z'): array
{
$entry = static fn (
string $color,
string $label,
string $folder = '',
string $stamp = '2026-09-27T22:21:39.000Z',
string $status = 'active'
): array => [
'color' => $color, 'folder' => $folder, 'label' => $label, 'lastUpdated' => $stamp,
'status' => $status, 'usedInPosts' => [],
];
return [
'gcid-primary-color' => $entry('#2ea3f2', 'Primärfarbe', 'customizer', $customizerStamp),
'gcid-secondary-color' => $entry('#2ea3f2', 'Sekundärfarbe', 'customizer', $customizerStamp),
'gcid-q9swzy97df' => $entry('#5b3a29', 'Holz dunkel'),
'gcid-989stx15zc' => $entry(self::ref('gcid-q9swzy97df', ['lightness' => 30]), 'Holz hell'),
'gcid-aaaaaaaaaa' => $entry('#111111', 'Doppelt'),
'gcid-bbbbbbbbbb' => $entry('#222222', 'doppelt', '', '2026-09-27T22:21:39.000Z', 'inactive'),
];
}
/** @param array<string, int|float> $settings */
public static function ref(string $id, array $settings = []): string
{
return VariableRef::serialize('color', $id, $settings);
}
public function testMissingKeysAreFilledAndOnlyTheSixKeysStay(): void
{
$book = ColorBook::fromDivi([
'gcid-x1' => ['color' => '#fff', 'extra' => 'dropped'],
'gcid-x2' => (object) ['color' => '#000', 'label' => 'Schwarz', 'usedInPosts' => [4 => 12]],
'gcid-x3' => 'not an entry',
]);
self::assertSame(['gcid-x1', 'gcid-x2'], array_keys($book->data()));
self::assertSame([
'color' => '#fff', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active',
'usedInPosts' => [],
], $book->entry('gcid-x1'));
self::assertSame([12], $book->entry('gcid-x2')['usedInPosts']);
self::assertSame('Schwarz', $book->label('gcid-x2'));
self::assertSame('gcid-x1', $book->label('gcid-x1'), 'a color without a label shows its ID');
self::assertNull($book->entry('gcid-nope'));
}
public function testObjectInputIsAcceptedLikeAnArray(): void
{
$raw = new stdClass();
$raw->{'gcid-x1'} = (object) ['color' => '#fff', 'status' => 'inactive'];
self::assertSame(ColorBook::fromDivi(['gcid-x1' => ['color' => '#fff', 'status' => 'inactive']])->data(),
ColorBook::fromDivi($raw)->data());
}
public function testCustomizerLastUpdatedIsNormalizedSoRevStaysStable(): void
{
$now = ColorBook::fromDivi(self::stored('2026-10-19T08:00:00.000Z'));
$later = ColorBook::fromDivi(self::stored('2026-10-19T09:30:00.000Z'));
self::assertSame('', $now->entry('gcid-primary-color')['lastUpdated']);
self::assertSame('2026-09-27T22:21:39.000Z', $now->entry('gcid-q9swzy97df')['lastUpdated']);
self::assertSame($now->rev(), $later->rev());
self::assertSame($now->data(), $later->data());
self::assertSame([], $later->changedSince($now));
}
public function testRevIgnoresTheOrderOfEntriesAndChangesWithTheContent(): void
{
$stored = self::stored();
$book = ColorBook::fromDivi($stored);
$reversed = ColorBook::fromDivi(array_reverse($stored, true));
self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev());
self::assertSame($book->rev(), $reversed->rev());
self::assertSame($book->rev(), ColorBook::fromDivi($stored)->rev());
self::assertNotSame($book->rev(), $book->with('gcid-q9swzy97df', ['color' => '#000000'])->rev());
}
public function testRealDiviFixtureAndTheSnapshotOfItAreTheSame(): void
{
$json = (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/stage4/global-colors.json');
$fixture = json_decode($json, true);
$read = ColorBook::fromDivi($fixture['get_global_colors']);
$snapshot = ColorBook::fromDivi($read->data());
self::assertCount(9, $read->data());
self::assertSame($read->rev(), $snapshot->rev());
self::assertTrue($read->isCustomizer('gcid-link-color'));
self::assertFalse($read->isCustomizer('gcid-q9swzy97df'));
self::assertSame(['gcid-989stx15zc'], $read->referencing('gcid-q9swzy97df'));
self::assertNull($read->cycle());
}
public function testIsCustomizerByIdOrFolder(): void
{
$book = ColorBook::fromDivi([
'gcid-body-color' => ['color' => '#666'],
'gcid-legacy' => ['color' => '#666', 'folder' => 'customizer'],
'gcid-x1' => ['color' => '#666'],
]);
self::assertTrue($book->isCustomizer('gcid-body-color'));
self::assertTrue($book->isCustomizer('gcid-legacy'));
self::assertFalse($book->isCustomizer('gcid-x1'));
self::assertTrue($book->isCustomizer('gcid-heading-color'), 'a customizer ID counts even if it is missing');
self::assertSame('', $book->entry('gcid-legacy')['lastUpdated']);
}
public function testFindByIdAliasOptionNameAndLabel(): void
{
$book = ColorBook::fromDivi(self::stored());
self::assertSame('gcid-q9swzy97df', $book->find('gcid-q9swzy97df'));
self::assertSame('gcid-primary-color', $book->find('primary'));
self::assertSame('gcid-primary-color', $book->find('PRIMARY'));
self::assertSame('gcid-secondary-color', $book->find('secondary_accent_color'));
self::assertSame('gcid-q9swzy97df', $book->find(' holz DUNKEL '));
self::assertSame('gcid-primary-color', $book->find('Primärfarbe'));
self::assertNull($book->find('heading'), 'an alias whose color does not exist finds nothing');
self::assertNull($book->find('Fehlt'));
self::assertNull($book->find(''));
}
public function testAmbiguousLabelListsTheIds(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
'Name der globalen Farbe "Doppelt" ist mehrdeutig; bitte die ID angeben: gcid-aaaaaaaaaa, gcid-bbbbbbbbbb.'
);
ColorBook::fromDivi(self::stored())->find('Doppelt');
}
public function testWithAddsOrReplacesAndLeavesTheOriginalAlone(): void
{
$book = ColorBook::fromDivi(self::stored());
$added = $book->with('gcid-new0000001', ['color' => '#abc', 'label' => 'Neu']);
$replaced = $book->with('gcid-q9swzy97df', ['color' => '#000000', 'label' => 'Holz', 'status' => 'inactive']);
self::assertCount(6, $book->data());
self::assertCount(7, $added->data());
self::assertSame('active', $added->entry('gcid-new0000001')['status']);
self::assertSame('inactive', $replaced->entry('gcid-q9swzy97df')['status']);
self::assertSame('#5b3a29', $book->entry('gcid-q9swzy97df')['color']);
self::assertSame('', $book->with('gcid-primary-color', ['color' => '#fff', 'lastUpdated' => 'x'])
->entry('gcid-primary-color')['lastUpdated']);
}
public function testReferencingFindsRelativeColorsIncludingSelfReferences(): void
{
$book = ColorBook::fromDivi(self::stored())
->with('gcid-self000001', ['color' => self::ref('gcid-self000001')])
->with('gcid-text000001', ['color' => 'Hinweis: gcid-q9swzy97df']);
self::assertSame(['gcid-989stx15zc'], $book->referencing('gcid-q9swzy97df'));
self::assertSame(['gcid-self000001'], $book->referencing('gcid-self000001'));
self::assertSame([], $book->referencing('gcid-989stx15zc'));
}
public function testCycleFindsLoopsAndSelfReferences(): void
{
$loop = ColorBook::fromDivi([
'gcid-a' => ['color' => self::ref('gcid-b')],
'gcid-b' => ['color' => self::ref('gcid-c', ['hue' => 10])],
'gcid-c' => ['color' => self::ref('gcid-b')],
]);
$self = ColorBook::fromDivi(['gcid-a' => ['color' => self::ref('gcid-a')]]);
self::assertSame(['gcid-a', 'gcid-b', 'gcid-c', 'gcid-b'], $loop->cycle());
self::assertSame(['gcid-a', 'gcid-a'], $self->cycle());
self::assertNull(ColorBook::fromDivi(self::stored())->cycle());
}
public function testCycleStopsAtMoreThanTenReferences(): void
{
$chain = static function (int $references): ColorBook {
$raw = [];
for ($i = 0; $i < $references; ++$i) {
$raw['gcid-c' . $i] = ['color' => self::ref('gcid-c' . ($i + 1))];
}
$raw['gcid-c' . $references] = ['color' => '#123456'];
return ColorBook::fromDivi($raw);
};
self::assertNull($chain(10)->cycle(), 'ten references still resolve in Divi');
$tooDeep = $chain(11)->cycle();
self::assertCount(12, $tooDeep);
self::assertSame(['gcid-c0', 'gcid-c1'], array_slice($tooDeep, 0, 2));
}
public function testReferenceToAnUnknownColorIsNoCycle(): void
{
self::assertNull(ColorBook::fromDivi(['gcid-a' => ['color' => self::ref('gcid-missing')]])->cycle());
}
public function testChangedSinceListsNewAndChangedEntriesOnly(): void
{
$before = ColorBook::fromDivi(self::stored());
$after = $before
->with('gcid-q9swzy97df', ['color' => '#000000', 'label' => 'Holz dunkel', 'lastUpdated' => 'x'])
->with('gcid-new0000001', ['color' => '#abc', 'label' => 'Neu'])
->with('gcid-aaaaaaaaaa', $before->entry('gcid-aaaaaaaaaa') ?? []);
self::assertSame(['gcid-q9swzy97df', 'gcid-new0000001'], array_keys($after->changedSince($before)));
self::assertSame([], $before->changedSince($before));
self::assertSame(['gcid-primary-color'], array_keys(
$before->with('gcid-primary-color', ['color' => '#ff0000', 'folder' => 'customizer'])->changedSince($before)
));
}
public function testNewIdHasTheBuilderFormat(): void
{
self::assertMatchesRegularExpression('/^gcid-[0-9a-z]{10}$/', ColorBook::newId());
self::assertNotSame(ColorBook::newId(), ColorBook::newId());
self::assertSame(
'gcid-abcdefghij',
ColorBook::newId(static fn (int $length): string => substr('abcdefghijkl', 0, $length))
);
}
public function testNowHasDiviTimestampFormat(): void
{
self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', ColorBook::now());
}
}