feat: lit/divi-colors-set löst {{color:…}} mit relative gegen den geplanten Bestand auf, lehnt Zyklen ab und warnt vor Verwendungen in Presets und relativen Farben
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
337b87b572
commit
2fd03f3900
@@ -9,7 +9,9 @@ use InvalidArgumentException;
|
|||||||
use LIT\MCP\Abilities\Ability;
|
use LIT\MCP\Abilities\Ability;
|
||||||
use LIT\MCP\Abilities\Annotations;
|
use LIT\MCP\Abilities\Annotations;
|
||||||
use LIT\MCP\Abilities\Errors;
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
@@ -20,8 +22,10 @@ defined('ABSPATH') || exit;
|
|||||||
* lit/divi-colors-set (spec §7.1, rulings V3–V5, V8): creates and changes global colors through the change pipeline
|
* lit/divi-colors-set (spec §7.1, rulings V3–V5, V8): creates and changes global colors through the change pipeline
|
||||||
* (ColorArea). An item names an existing color in id (ID, label, alias primary … or option name accent_color …) or
|
* (ColorArea). An item names an existing color in id (ID, label, alias primary … or option name accent_color …) or
|
||||||
* creates one from label and color. Colors are never deleted, only deactivated; the five main colors only change
|
* creates one from label and color. Colors are never deleted, only deactivated; the five main colors only change
|
||||||
* their value. The dry run lists the posts that reference a changed, deactivated or activated color
|
* their value, never to a reference. {{color:…}} with relative is resolved against the planned inventory – also
|
||||||
* (ReferenceUsageQuery).
|
* colors of the same call, in item order – and stored as Divi reference (VariableRef); cycles and chains deeper than
|
||||||
|
* 10 are refused. The dry run lists the posts that reference a changed, deactivated or activated color
|
||||||
|
* (ReferenceUsageQuery) and warns about presets and relative colors built on it.
|
||||||
*/
|
*/
|
||||||
final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
||||||
{
|
{
|
||||||
@@ -39,6 +43,17 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
|||||||
private const NEW_ENTRY = ['color' => '', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active',
|
private const NEW_ENTRY = ['color' => '', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active',
|
||||||
'usedInPosts' => []];
|
'usedInPosts' => []];
|
||||||
|
|
||||||
|
private const PLACEHOLDER = '/^\{\{\s*color\s*:\s*([^{}]+?)\s*\}\}$/D';
|
||||||
|
|
||||||
|
/** Exception code of value(): the problem belongs to the item's relative, not to its color. */
|
||||||
|
private const RELATIVE = 1;
|
||||||
|
|
||||||
|
/** At most this many names per warning. */
|
||||||
|
private const LISTED = 10;
|
||||||
|
|
||||||
|
private const IN_USE = 'Die unter affected genannten Inhalte verweisen auf geänderte oder deaktivierte Farben; '
|
||||||
|
. 'deaktivierte Farben bleiben dort als Verweis stehen.';
|
||||||
|
|
||||||
/** @param (Closure(int): string)|null $random Random characters for new IDs (ColorBook::newId(); tests). */
|
/** @param (Closure(int): string)|null $random Random characters for new IDs (ColorBook::newId(); tests). */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private DesignSystemWriter $writer,
|
private DesignSystemWriter $writer,
|
||||||
@@ -172,6 +187,13 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
|||||||
}
|
}
|
||||||
$book = $this->apply($book, $item, $field, $errors);
|
$book = $this->apply($book, $item, $field, $errors);
|
||||||
}
|
}
|
||||||
|
$cycle = $errors === [] ? $book->cycle() : null;
|
||||||
|
if ($cycle !== null) {
|
||||||
|
$errors['items'] = sprintf(
|
||||||
|
'Relative Farben bauen im Kreis aufeinander auf oder sind tiefer als 10 Stufen verschachtelt: %s',
|
||||||
|
implode(' → ', array_map($book->label(...), $cycle))
|
||||||
|
);
|
||||||
|
}
|
||||||
if ($errors !== []) {
|
if ($errors !== []) {
|
||||||
return Errors::invalidInput('Ungültige Farb-Angaben; nichts wurde gespeichert.', $errors);
|
return Errors::invalidInput('Ungültige Farb-Angaben; nichts wurde gespeichert.', $errors);
|
||||||
}
|
}
|
||||||
@@ -184,12 +206,14 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$touched = array_values(array_unique($touched));
|
$touched = array_values(array_unique($touched));
|
||||||
|
$report = $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched));
|
||||||
|
|
||||||
return new DesignPlan(
|
return new DesignPlan(
|
||||||
$book->data(),
|
$book->data(),
|
||||||
$diff,
|
$diff,
|
||||||
ColorDiff::summary($diff),
|
ColorDiff::summary($diff),
|
||||||
$touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched))
|
$report,
|
||||||
|
$this->warnings($book, $diff, $report)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,12 +249,15 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
|||||||
'label' => self::cleanLabel($book, $item['label'], $id),
|
'label' => self::cleanLabel($book, $item['label'], $id),
|
||||||
'folder' => self::folder($item['folder']),
|
'folder' => self::folder($item['folder']),
|
||||||
'status' => self::status($item['status']),
|
'status' => self::status($item['status']),
|
||||||
default => self::value($item),
|
default => self::value($book, $item, $main),
|
||||||
};
|
};
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
$errors[$field . '.' . $key] = $e->getMessage();
|
$errors[$field . '.' . ($e->getCode() === self::RELATIVE ? 'relative' : $key)] = $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('relative', $item) && ! array_key_exists('color', $item)) {
|
||||||
|
$errors[$field . '.relative'] = 'nur zusammen mit color als Platzhalter {{color:…}}';
|
||||||
|
}
|
||||||
if (count($errors) > $known || ($id !== null && $entry === $book->entry($id))) {
|
if (count($errors) > $known || ($id !== null && $entry === $book->entry($id))) {
|
||||||
return $book;
|
return $book;
|
||||||
}
|
}
|
||||||
@@ -313,16 +340,24 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* The stored value of $item's color (ruling V5): a checked fixed value, or {{color:…}} with relative resolved
|
||||||
|
* against $book (the planned inventory) into a Divi reference. A main color only takes a fixed value – Divi keeps
|
||||||
|
* it in et_divi, where a reference would break the Customizer.
|
||||||
|
*
|
||||||
* @param array<array-key, mixed> $item
|
* @param array<array-key, mixed> $item
|
||||||
* @throws InvalidArgumentException
|
* @throws InvalidArgumentException Code RELATIVE when the problem is in relative.
|
||||||
*/
|
*/
|
||||||
private static function value(array $item): string
|
private static function value(ColorBook $book, array $item, bool $main): string
|
||||||
{
|
{
|
||||||
$color = $item['color'];
|
$color = $item['color'];
|
||||||
if (! is_string($color)) {
|
if (! is_string($color)) {
|
||||||
throw new InvalidArgumentException('Farbwert als Text erwartet');
|
throw new InvalidArgumentException('Farbwert als Text erwartet');
|
||||||
}
|
}
|
||||||
$color = trim($color);
|
$color = trim($color);
|
||||||
|
if (preg_match(self::PLACEHOLDER, $color, $match) !== 1) {
|
||||||
|
if (array_key_exists('relative', $item)) {
|
||||||
|
throw new InvalidArgumentException('nur zusammen mit einem Platzhalter {{color:…}}', self::RELATIVE);
|
||||||
|
}
|
||||||
$problem = ColorValue::check($color);
|
$problem = ColorValue::check($color);
|
||||||
if ($problem !== null) {
|
if ($problem !== null) {
|
||||||
throw new InvalidArgumentException($problem);
|
throw new InvalidArgumentException($problem);
|
||||||
@@ -331,6 +366,28 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
|||||||
return $color;
|
return $color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($main) {
|
||||||
|
throw new InvalidArgumentException('Hauptfarben brauchen einen festen Farbwert, keinen Platzhalter');
|
||||||
|
}
|
||||||
|
$base = $book->find($match[1]);
|
||||||
|
if ($base === null) {
|
||||||
|
throw new InvalidArgumentException(sprintf('unbekannte Farbe „%s“ im Platzhalter', $match[1]));
|
||||||
|
}
|
||||||
|
$relative = $item['relative'] ?? null;
|
||||||
|
if (! is_array($relative)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Pflicht mit einem Platzhalter: {hue, saturation, lightness, opacity}',
|
||||||
|
self::RELATIVE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$settings = ColorValue::relative($relative);
|
||||||
|
if (is_string($settings)) {
|
||||||
|
throw new InvalidArgumentException($settings, self::RELATIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return VariableRef::serialize('color', $base, $settings);
|
||||||
|
}
|
||||||
|
|
||||||
private function newId(ColorBook $book): string
|
private function newId(ColorBook $book): string
|
||||||
{
|
{
|
||||||
do {
|
do {
|
||||||
@@ -339,4 +396,57 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
|||||||
|
|
||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling V8: presets and relative colors built on a changed, deactivated or activated color are warnings, never
|
||||||
|
* a block.
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $diff
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function warnings(ColorBook $book, array $diff, ?UsageReport $report): array
|
||||||
|
{
|
||||||
|
$presets = null;
|
||||||
|
$warnings = [];
|
||||||
|
$deactivates = false;
|
||||||
|
foreach ($diff as $row) {
|
||||||
|
if ($row['action'] === 'added') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$deactivated = $row['action'] === 'deactivated';
|
||||||
|
$deactivates = $deactivates || $deactivated;
|
||||||
|
$presets ??= PresetBook::fromDivi($this->writer->presets());
|
||||||
|
$users = array_column($presets->referencing((string) $row['id']), 'name');
|
||||||
|
if ($users !== []) {
|
||||||
|
$warnings[] = sprintf(
|
||||||
|
$deactivated
|
||||||
|
? 'Farbe „%s“ wird deaktiviert, aber noch von Presets verwendet: %s.'
|
||||||
|
: 'Die Änderung an Farbe „%s“ wirkt auch auf die Presets %s.',
|
||||||
|
$row['name'],
|
||||||
|
self::names($users)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$derived = array_map($book->label(...), $book->referencing((string) $row['id']));
|
||||||
|
if ($derived !== []) {
|
||||||
|
$warnings[] = sprintf(
|
||||||
|
$deactivated
|
||||||
|
? 'Farbe „%1$s“ wird deaktiviert, aber die relativen Farben %2$s bauen auf ihr auf.'
|
||||||
|
: 'Die relativen Farben %2$s bauen auf Farbe „%1$s“ auf und ändern sich mit.',
|
||||||
|
$row['name'],
|
||||||
|
self::names($derived)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($deactivates && $report !== null && $report->exists()) {
|
||||||
|
$warnings[] = self::IN_USE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $names */
|
||||||
|
private static function names(array $names): string
|
||||||
|
{
|
||||||
|
return implode(', ', array_slice($names, 0, self::LISTED)) . (count($names) > self::LISTED ? ', …' : '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
|||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use LIT\MCP\Abilities\Annotations;
|
use LIT\MCP\Abilities\Annotations;
|
||||||
use LIT\MCP\Abilities\WriteFailed;
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Design\ColorBook;
|
use LIT\MCP\Integrations\Divi\Design\ColorBook;
|
||||||
use LIT\MCP\Integrations\Divi\Design\ColorDiff;
|
use LIT\MCP\Integrations\Divi\Design\ColorDiff;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\ColorValue;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility;
|
use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility;
|
||||||
@@ -300,4 +302,140 @@ final class DiviColorsSetAbilityTest extends TestCase
|
|||||||
}
|
}
|
||||||
self::assertSame([false, true], $calls, 'the merge failed, the snapshot replaced all colors');
|
self::assertSame([false, true], $calls, 'the merge failed, the snapshot replaced all colors');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling V5: {{color:…}} with relative is stored as Divi reference on the named color's ID. */
|
||||||
|
public function testAPlaceholderBecomesARelativeColorOfTheNamedColor(): void
|
||||||
|
{
|
||||||
|
$ability = $this->ability(null, self::snapshots());
|
||||||
|
$input = ['items' => [
|
||||||
|
['label' => 'Holz hell', 'color' => '{{color:Holz dunkel}}', 'relative' => ['lightness' => 30]],
|
||||||
|
]];
|
||||||
|
|
||||||
|
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||||||
|
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||||
|
|
||||||
|
$created = array_values(array_diff_key($this->stored, ColorAreaTest::stored()));
|
||||||
|
self::assertSame(
|
||||||
|
VariableRef::serialize('color', 'gcid-holz000001', ColorValue::relative(['lightness' => 30])),
|
||||||
|
$created[0]['color']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling V5: the placeholder is resolved against the planned inventory, also colors of the same call. */
|
||||||
|
public function testAPlaceholderMayNameAColorOfTheSameCall(): void
|
||||||
|
{
|
||||||
|
$ability = $this->ability(null, self::snapshots());
|
||||||
|
$input = ['items' => [
|
||||||
|
['label' => 'Basis', 'color' => '#112233'],
|
||||||
|
['label' => 'Basis hell', 'color' => '{{color:basis}}', 'relative' => ['lightness' => 20]],
|
||||||
|
]];
|
||||||
|
|
||||||
|
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||||||
|
self::assertSame(['added', 'added'], array_column($dry['diff'], 'action'));
|
||||||
|
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||||
|
|
||||||
|
$byLabel = [];
|
||||||
|
foreach ($this->stored as $id => $entry) {
|
||||||
|
$byLabel[$entry['label']] = ['id' => $id] + $entry;
|
||||||
|
}
|
||||||
|
self::assertSame(
|
||||||
|
VariableRef::serialize('color', $byLabel['Basis']['id'], ColorValue::relative(['lightness' => 20])),
|
||||||
|
$byLabel['Basis hell']['color']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlaceholderProblemsAreCollected(): void
|
||||||
|
{
|
||||||
|
$error = $this->ability()->execute(['items' => [
|
||||||
|
['label' => 'A', 'color' => '{{color:Fehlt}}', 'relative' => ['lightness' => 10]],
|
||||||
|
['label' => 'B', 'color' => '#ffffff', 'relative' => ['lightness' => 10]],
|
||||||
|
['id' => 'primary', 'color' => '{{color:Holz dunkel}}', 'relative' => ['lightness' => 10]],
|
||||||
|
['label' => 'C', 'color' => '{{color:Holz dunkel}}', 'relative' => ['farbe' => 10]],
|
||||||
|
['id' => 'Sand', 'relative' => ['lightness' => 10]],
|
||||||
|
['label' => 'D', 'color' => VariableRef::serialize('color', 'gcid-holz000001', [])],
|
||||||
|
['label' => 'E', 'color' => '{{color:Holz dunkel}}'],
|
||||||
|
]]);
|
||||||
|
|
||||||
|
self::assertToolError($error, 'validation_failed');
|
||||||
|
self::assertSame(
|
||||||
|
['items[0].color', 'items[1].relative', 'items[2].color', 'items[3].relative', 'items[4].relative',
|
||||||
|
'items[5].color', 'items[6].relative'],
|
||||||
|
array_keys(self::errorFields($error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling V5: Sand is built on Holz dunkel – Holz dunkel built on Sand would be a cycle. */
|
||||||
|
public function testRelativeColorsMustNotFormACycle(): void
|
||||||
|
{
|
||||||
|
$error = $this->ability()->execute(['items' => [
|
||||||
|
['id' => 'Holz dunkel', 'color' => '{{color:Sand}}', 'relative' => ['lightness' => -40]],
|
||||||
|
]]);
|
||||||
|
|
||||||
|
self::assertToolError($error, 'validation_failed');
|
||||||
|
$fields = self::errorFields($error);
|
||||||
|
self::assertSame(['items'], array_keys($fields));
|
||||||
|
self::assertStringContainsString('Sand', $fields['items']);
|
||||||
|
self::assertSame([], $this->saves);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling V8: presets and relative colors built on a changed color are warnings, never a block. */
|
||||||
|
public function testChangesWarnAboutPresetsAndRelativeColorsBuiltOnTheColor(): void
|
||||||
|
{
|
||||||
|
$dry = self::assertToolError(
|
||||||
|
$this->ability(null, null, PresetBookTest::storedWithReferences())
|
||||||
|
->execute(['items' => [['id' => 'Holz dunkel', 'color' => '#000000']]]),
|
||||||
|
'confirm_required'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([
|
||||||
|
'Die Änderung an Farbe „Holz dunkel“ wirkt auch auf die Presets CTA primär.',
|
||||||
|
'Die relativen Farben Sand bauen auf Farbe „Holz dunkel“ auf und ändern sich mit.',
|
||||||
|
], $dry['warnings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeactivatingAUsedColorWarnsAndListsTheUsages(): void
|
||||||
|
{
|
||||||
|
$dry = self::assertToolError(
|
||||||
|
$this->ability(self::usages(self::used()), null, PresetBookTest::storedWithReferences())
|
||||||
|
->execute(['items' => [['id' => 'Holz dunkel', 'status' => 'inactive']]]),
|
||||||
|
'confirm_required'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(7, $dry['affected'][0]['post_id']);
|
||||||
|
self::assertSame([
|
||||||
|
'Farbe „Holz dunkel“ wird deaktiviert, aber noch von Presets verwendet: CTA primär.',
|
||||||
|
'Farbe „Holz dunkel“ wird deaktiviert, aber die relativen Farben Sand bauen auf ihr auf.',
|
||||||
|
'Die unter affected genannten Inhalte verweisen auf geänderte oder deaktivierte Farben; deaktivierte '
|
||||||
|
. 'Farben bleiben dort als Verweis stehen.',
|
||||||
|
], $dry['warnings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNewColorsHaveNoWarnings(): void
|
||||||
|
{
|
||||||
|
$dry = self::assertToolError(
|
||||||
|
$this->ability(null, null, PresetBookTest::storedWithReferences())
|
||||||
|
->execute(['items' => [['label' => 'Weiß', 'color' => 'white']]]),
|
||||||
|
'confirm_required'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $dry['warnings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Review note: ColorBook::with() replaces, so a changed color keeps usedInPosts and its other fields. */
|
||||||
|
public function testChangingAColorKeepsItsUsedInPostsAndOtherFields(): void
|
||||||
|
{
|
||||||
|
$this->stored['gcid-holz000001']['usedInPosts'] = [7, 9];
|
||||||
|
$ability = $this->ability();
|
||||||
|
$input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']]];
|
||||||
|
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||||||
|
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||||
|
$snapshots->allows('save')->andReturn('colors-1');
|
||||||
|
$snapshots->allows('attachLog');
|
||||||
|
|
||||||
|
$this->ability(null, $snapshots)->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||||
|
|
||||||
|
$saved = $this->saves[0][0]['gcid-holz000001'];
|
||||||
|
self::assertSame([7, 9], $saved['usedInPosts']);
|
||||||
|
self::assertSame(['Holz dunkel', '#000000'], [$saved['label'], $saved['color']]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user