feat: ThemeOptionSpec – die 20 Theme-Optionen mit Prüfung, Normalisierung und Vererbung (W2–W4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
a90cd7f5bd
commit
a9edd850c1
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 20 theme options the plugin manages (ruling W2) with what Divi's customizer says about them
|
||||||
|
* (et_divi_customizer_theme_settings(), Divi 5.15.1): type, default, range, choices, inheritance. Divi's own
|
||||||
|
* sanitizers do not clamp (ruling W4), so check() is where min/max/step/choices are enforced. The font list is
|
||||||
|
* not checked here (FontCatalog does). Pure PHP – a copy of the customizer literals, guarded by the mirror
|
||||||
|
* "customizer-typography".
|
||||||
|
*/
|
||||||
|
final class ThemeOptionSpec
|
||||||
|
{
|
||||||
|
/** The whitelist in the order of ruling W2; books, diffs and the tool output use it. */
|
||||||
|
public const KEYS = [
|
||||||
|
'heading_font', 'body_font', 'primary_nav_font', 'secondary_nav_font',
|
||||||
|
'heading_font_weight', 'body_font_weight',
|
||||||
|
'body_font_size', 'tablet_body_font_size', 'phone_body_font_size',
|
||||||
|
'body_header_size', 'tablet_header_font_size', 'phone_header_font_size',
|
||||||
|
'body_font_height', 'body_header_height', 'body_header_spacing', 'body_header_style',
|
||||||
|
'content_width', 'gutter_width', 'section_padding', 'row_padding',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const MAX_FONT_LENGTH = 100;
|
||||||
|
|
||||||
|
private const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900'];
|
||||||
|
|
||||||
|
private const STYLES = ['italic', 'uppercase', 'underline'];
|
||||||
|
|
||||||
|
private const BODY_SIZE = ['type' => 'int', 'default' => 14, 'min' => 10, 'max' => 32, 'step' => 1];
|
||||||
|
|
||||||
|
private const HEADER_SIZE = ['type' => 'int', 'default' => 30, 'min' => 22, 'max' => 72, 'step' => 1];
|
||||||
|
|
||||||
|
private const LINE_HEIGHT = ['type' => 'float', 'min' => 0.8, 'max' => 3, 'step' => 0.1];
|
||||||
|
|
||||||
|
private const WEIGHT = ['type' => 'weight', 'default' => '500', 'choices' => self::WEIGHTS];
|
||||||
|
|
||||||
|
private const FONT = ['type' => 'font', 'default' => null];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tablet/phone sizes inherit the next larger one while unset (ruling W3); their default is what Divi's CSS
|
||||||
|
* readers assume (14 / 30) when nothing is stored at all.
|
||||||
|
*/
|
||||||
|
private const DEFINITIONS = [
|
||||||
|
'heading_font' => ['label' => 'Überschrift-Schrift', ...self::FONT],
|
||||||
|
'body_font' => ['label' => 'Fließtext-Schrift', ...self::FONT],
|
||||||
|
'primary_nav_font' => ['label' => 'Hauptmenü-Schrift', ...self::FONT],
|
||||||
|
'secondary_nav_font' => ['label' => 'Nebenmenü-Schrift', ...self::FONT],
|
||||||
|
'heading_font_weight' => ['label' => 'Überschrift-Schriftstärke', ...self::WEIGHT],
|
||||||
|
'body_font_weight' => ['label' => 'Fließtext-Schriftstärke', ...self::WEIGHT],
|
||||||
|
'body_font_size' => ['label' => 'Fließtext-Schriftgröße', ...self::BODY_SIZE],
|
||||||
|
'tablet_body_font_size' => [
|
||||||
|
'label' => 'Fließtext-Schriftgröße (Tablet)', 'inherits' => 'body_font_size', ...self::BODY_SIZE,
|
||||||
|
],
|
||||||
|
'phone_body_font_size' => [
|
||||||
|
'label' => 'Fließtext-Schriftgröße (Smartphone)', 'inherits' => 'tablet_body_font_size',
|
||||||
|
...self::BODY_SIZE,
|
||||||
|
],
|
||||||
|
'body_header_size' => ['label' => 'Überschrift-Schriftgröße', ...self::HEADER_SIZE],
|
||||||
|
'tablet_header_font_size' => [
|
||||||
|
'label' => 'Überschrift-Schriftgröße (Tablet)', 'inherits' => 'body_header_size', ...self::HEADER_SIZE,
|
||||||
|
],
|
||||||
|
'phone_header_font_size' => [
|
||||||
|
'label' => 'Überschrift-Schriftgröße (Smartphone)', 'inherits' => 'tablet_header_font_size',
|
||||||
|
...self::HEADER_SIZE,
|
||||||
|
],
|
||||||
|
'body_font_height' => ['label' => 'Fließtext-Zeilenhöhe', 'default' => 1.7, ...self::LINE_HEIGHT],
|
||||||
|
'body_header_height' => ['label' => 'Überschrift-Zeilenhöhe', 'default' => 1.0, ...self::LINE_HEIGHT],
|
||||||
|
'body_header_spacing' => [
|
||||||
|
'type' => 'int', 'label' => 'Überschrift-Buchstabenabstand', 'default' => 0,
|
||||||
|
'min' => -2, 'max' => 10, 'step' => 1,
|
||||||
|
],
|
||||||
|
'body_header_style' => [
|
||||||
|
'type' => 'style', 'label' => 'Überschrift-Schriftstil', 'default' => null, 'choices' => self::STYLES,
|
||||||
|
],
|
||||||
|
'content_width' => [
|
||||||
|
'type' => 'int', 'label' => 'Inhaltsbreite', 'default' => 1080, 'min' => 960, 'max' => 1920, 'step' => 1,
|
||||||
|
],
|
||||||
|
'gutter_width' => [
|
||||||
|
'type' => 'int', 'label' => 'Spaltenabstand', 'default' => 3, 'min' => 1, 'max' => 4, 'step' => 1,
|
||||||
|
],
|
||||||
|
'section_padding' => [
|
||||||
|
'type' => 'int', 'label' => 'Abschnitt-Innenabstand', 'default' => 4, 'min' => 0, 'max' => 10, 'step' => 1,
|
||||||
|
],
|
||||||
|
'row_padding' => [
|
||||||
|
'type' => 'int', 'label' => 'Zeile-Innenabstand', 'default' => 2, 'min' => 0, 'max' => 10, 'step' => 1,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{type: 'int'|'float'|'font'|'weight'|'style', label: string, default: int|float|string|null,
|
||||||
|
* min?: int|float, max?: int|float, step?: int|float, choices?: list<string>, inherits?: string}
|
||||||
|
* @throws InvalidArgumentException For a key outside the whitelist.
|
||||||
|
*/
|
||||||
|
public static function definition(string $key): array
|
||||||
|
{
|
||||||
|
return self::DEFINITIONS[$key] ?? throw new InvalidArgumentException(
|
||||||
|
sprintf('Unbekannte Theme-Option "%s".', $key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isKey(string $key): bool
|
||||||
|
{
|
||||||
|
return isset(self::DEFINITIONS[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type, range, step and choices of $value; null = valid, else a German message. null as a value is "unset" –
|
||||||
|
* the caller handles it before check() – and counts as invalid here. "none" and false are no font (ruling W3).
|
||||||
|
*/
|
||||||
|
public static function check(string $key, mixed $value): ?string
|
||||||
|
{
|
||||||
|
$definition = self::definition($key);
|
||||||
|
$label = $definition['label'];
|
||||||
|
|
||||||
|
return match ($definition['type']) {
|
||||||
|
'int', 'float' => self::checkNumber($label, $definition, $value),
|
||||||
|
'font' => self::checkFont($label, $value),
|
||||||
|
default => self::checkChoice($label, $definition['choices'] ?? [], $value, $definition['type']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value in the type Divi stores: int, float, weight as string, font and style as trimmed string.
|
||||||
|
* Only meaningful for a value check() accepted.
|
||||||
|
*/
|
||||||
|
public static function normalize(string $key, mixed $value): int|float|string
|
||||||
|
{
|
||||||
|
return match (self::definition($key)['type']) {
|
||||||
|
'int' => (int) self::number($value),
|
||||||
|
'float' => (float) self::number($value),
|
||||||
|
'weight' => (string) (int) self::number($value),
|
||||||
|
default => trim((string) (is_scalar($value) ? $value : '')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Divi uses for $key: the stored value, else (while unset) the effective value of the key it inherits from
|
||||||
|
* (ruling W3), else Divi's default; null when there is neither (a font or style left at Divi's default).
|
||||||
|
* A stored value that is false, null or no usable value counts as unset.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $stored
|
||||||
|
*/
|
||||||
|
public static function effective(string $key, array $stored): int|float|string|null
|
||||||
|
{
|
||||||
|
$definition = self::definition($key);
|
||||||
|
$value = self::usable($definition['type'], $stored[$key] ?? null);
|
||||||
|
if ($value !== null) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($definition['inherits'])
|
||||||
|
? self::effective($definition['inherits'], $stored)
|
||||||
|
: $definition['default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $definition */
|
||||||
|
private static function checkNumber(string $label, array $definition, mixed $value): ?string
|
||||||
|
{
|
||||||
|
$number = self::number($value);
|
||||||
|
if ($number === null || ! is_finite((float) $number)) {
|
||||||
|
return sprintf('%s: erwartet eine Zahl, erhalten %s.', $label, self::describe($value));
|
||||||
|
}
|
||||||
|
if ($definition['type'] === 'int' && floor((float) $number) !== (float) $number) {
|
||||||
|
return sprintf('%s: erwartet eine ganze Zahl, erhalten %s.', $label, self::describe($value));
|
||||||
|
}
|
||||||
|
$min = $definition['min'];
|
||||||
|
$max = $definition['max'];
|
||||||
|
if ($number < $min || $number > $max) {
|
||||||
|
return sprintf(
|
||||||
|
'%s: %s liegt außerhalb von %s bis %s.',
|
||||||
|
$label,
|
||||||
|
self::decimal($number),
|
||||||
|
self::decimal($min),
|
||||||
|
self::decimal($max)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$steps = ($number - $min) / $definition['step'];
|
||||||
|
if (abs($steps - round($steps)) > 1e-6) {
|
||||||
|
return sprintf(
|
||||||
|
'%s: %s passt nicht zur Schrittweite %s (ab %s).',
|
||||||
|
$label,
|
||||||
|
self::decimal($number),
|
||||||
|
self::decimal($definition['step']),
|
||||||
|
self::decimal($min)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function checkFont(string $label, mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return sprintf('%s: erwartet einen Schriftnamen (Text), erhalten %s.', $label, self::describe($value));
|
||||||
|
}
|
||||||
|
$name = trim($value);
|
||||||
|
if ($name === '' || mb_strlen($name) > self::MAX_FONT_LENGTH) {
|
||||||
|
return sprintf('%s: der Schriftname muss 1 bis %d Zeichen lang sein.', $label, self::MAX_FONT_LENGTH);
|
||||||
|
}
|
||||||
|
if (preg_match('/[\x00-\x1f\x7f<>"\'`]/u', $name) === 1 || ! mb_check_encoding($name, 'UTF-8')) {
|
||||||
|
return sprintf('%s: der Schriftname enthält unzulässige Zeichen.', $label);
|
||||||
|
}
|
||||||
|
if (strtolower($name) === 'none') {
|
||||||
|
return sprintf('%s: "none" ist kein Schriftname; null setzt auf Divis Standardschrift zurück.', $label);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $choices */
|
||||||
|
private static function checkChoice(string $label, array $choices, mixed $value, string $type): ?string
|
||||||
|
{
|
||||||
|
$text = match (true) {
|
||||||
|
is_string($value) => $value,
|
||||||
|
is_int($value) && $type === 'weight' => (string) $value,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
if ($text === null || ! in_array($text, $choices, true)) {
|
||||||
|
return sprintf(
|
||||||
|
'%s: erlaubt ist genau ein Wert aus %s, erhalten %s.',
|
||||||
|
$label,
|
||||||
|
implode(', ', $choices),
|
||||||
|
self::describe($value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The number in $value (int, float or a plain decimal string), else null. */
|
||||||
|
private static function number(mixed $value): int|float|null
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
is_int($value), is_float($value) => $value,
|
||||||
|
is_string($value) && preg_match('/^-?\d+(\.\d+)?$/', $value) === 1 => $value + 0,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored value in the type of $type, or null when it is not usable (false, empty, garbage). */
|
||||||
|
private static function usable(string $type, mixed $value): int|float|string|null
|
||||||
|
{
|
||||||
|
if ($type === 'font' || $type === 'style') {
|
||||||
|
return is_string($value) && trim($value) !== '' && strtolower(trim($value)) !== 'none'
|
||||||
|
? trim($value)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
$number = self::number($value);
|
||||||
|
if ($number === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'int' => (int) $number,
|
||||||
|
'float' => (float) $number,
|
||||||
|
default => (string) (int) $number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function decimal(int|float $number): string
|
||||||
|
{
|
||||||
|
return str_replace('.', ',', (string) $number);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function describe(mixed $value): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
is_string($value) => '"' . mb_substr($value, 0, 40) . '"',
|
||||||
|
is_bool($value) => $value ? 'true' : 'false',
|
||||||
|
$value === null => 'null',
|
||||||
|
is_int($value), is_float($value) => var_export($value, true),
|
||||||
|
default => 'einen ' . get_debug_type($value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ThemeOptionSpecTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTheTwentyKeysKeepTheOrderOfRulingW2(): void
|
||||||
|
{
|
||||||
|
self::assertSame([
|
||||||
|
'heading_font', 'body_font', 'primary_nav_font', 'secondary_nav_font',
|
||||||
|
'heading_font_weight', 'body_font_weight',
|
||||||
|
'body_font_size', 'tablet_body_font_size', 'phone_body_font_size',
|
||||||
|
'body_header_size', 'tablet_header_font_size', 'phone_header_font_size',
|
||||||
|
'body_font_height', 'body_header_height', 'body_header_spacing', 'body_header_style',
|
||||||
|
'content_width', 'gutter_width', 'section_padding', 'row_padding',
|
||||||
|
], ThemeOptionSpec::KEYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheKeysAreTheKeysOfTheAdapterSanitizersInTheSameOrder(): void
|
||||||
|
{
|
||||||
|
self::assertSame(array_keys(ThemeOptionsAdapter::SANITIZERS), ThemeOptionSpec::KEYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, string, int|float|string|null, int|float|null, int|float|null}> */
|
||||||
|
public static function definitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'heading_font' => ['heading_font', 'font', null, null, null],
|
||||||
|
'body_font' => ['body_font', 'font', null, null, null],
|
||||||
|
'primary_nav_font' => ['primary_nav_font', 'font', null, null, null],
|
||||||
|
'secondary_nav_font' => ['secondary_nav_font', 'font', null, null, null],
|
||||||
|
'heading_font_weight' => ['heading_font_weight', 'weight', '500', null, null],
|
||||||
|
'body_font_weight' => ['body_font_weight', 'weight', '500', null, null],
|
||||||
|
'body_font_size' => ['body_font_size', 'int', 14, 10, 32],
|
||||||
|
'tablet_body_font_size' => ['tablet_body_font_size', 'int', 14, 10, 32],
|
||||||
|
'phone_body_font_size' => ['phone_body_font_size', 'int', 14, 10, 32],
|
||||||
|
'body_header_size' => ['body_header_size', 'int', 30, 22, 72],
|
||||||
|
'tablet_header_font_size' => ['tablet_header_font_size', 'int', 30, 22, 72],
|
||||||
|
'phone_header_font_size' => ['phone_header_font_size', 'int', 30, 22, 72],
|
||||||
|
'body_font_height' => ['body_font_height', 'float', 1.7, 0.8, 3],
|
||||||
|
'body_header_height' => ['body_header_height', 'float', 1.0, 0.8, 3],
|
||||||
|
'body_header_spacing' => ['body_header_spacing', 'int', 0, -2, 10],
|
||||||
|
'body_header_style' => ['body_header_style', 'style', null, null, null],
|
||||||
|
'content_width' => ['content_width', 'int', 1080, 960, 1920],
|
||||||
|
'gutter_width' => ['gutter_width', 'int', 3, 1, 4],
|
||||||
|
'section_padding' => ['section_padding', 'int', 4, 0, 10],
|
||||||
|
'row_padding' => ['row_padding', 'int', 2, 0, 10],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider definitions */
|
||||||
|
public function testEveryKeyHasItsDefinition(
|
||||||
|
string $key,
|
||||||
|
string $type,
|
||||||
|
mixed $default,
|
||||||
|
mixed $min,
|
||||||
|
mixed $max
|
||||||
|
): void
|
||||||
|
{
|
||||||
|
$definition = ThemeOptionSpec::definition($key);
|
||||||
|
|
||||||
|
self::assertTrue(ThemeOptionSpec::isKey($key));
|
||||||
|
self::assertSame($type, $definition['type']);
|
||||||
|
self::assertSame($default, $definition['default']);
|
||||||
|
self::assertNotSame('', $definition['label']);
|
||||||
|
self::assertSame($min, $definition['min'] ?? null);
|
||||||
|
self::assertSame($max, $definition['max'] ?? null);
|
||||||
|
self::assertSame(in_array($type, ['int', 'float'], true), isset($definition['step']));
|
||||||
|
self::assertSame(in_array($type, ['weight', 'style'], true), isset($definition['choices']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInheritanceAndChoicesAreDefined(): void
|
||||||
|
{
|
||||||
|
self::assertSame('body_font_size', ThemeOptionSpec::definition('tablet_body_font_size')['inherits']);
|
||||||
|
self::assertSame('tablet_body_font_size', ThemeOptionSpec::definition('phone_body_font_size')['inherits']);
|
||||||
|
self::assertSame('body_header_size', ThemeOptionSpec::definition('tablet_header_font_size')['inherits']);
|
||||||
|
self::assertSame('tablet_header_font_size', ThemeOptionSpec::definition('phone_header_font_size')['inherits']);
|
||||||
|
self::assertArrayNotHasKey('inherits', ThemeOptionSpec::definition('body_font_size'));
|
||||||
|
self::assertSame(
|
||||||
|
['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||||
|
ThemeOptionSpec::definition('body_font_weight')['choices']
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['italic', 'uppercase', 'underline'],
|
||||||
|
ThemeOptionSpec::definition('body_header_style')['choices']
|
||||||
|
);
|
||||||
|
self::assertSame(0.1, ThemeOptionSpec::definition('body_font_height')['step']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnknownKeyIsNoKeyAndHasNoDefinition(): void
|
||||||
|
{
|
||||||
|
self::assertFalse(ThemeOptionSpec::isKey('accent_color'));
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
ThemeOptionSpec::definition('accent_color');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, mixed}> */
|
||||||
|
public static function validValues(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'int lower bound' => ['body_font_size', 10],
|
||||||
|
'int upper bound' => ['body_font_size', 32],
|
||||||
|
'int as digits' => ['body_font_size', '15'],
|
||||||
|
'int as whole float' => ['body_font_size', 15.0],
|
||||||
|
'negative int bound' => ['body_header_spacing', -2],
|
||||||
|
'negative digits' => ['body_header_spacing', '-1'],
|
||||||
|
'width bounds' => ['content_width', 1920],
|
||||||
|
'zero padding' => ['section_padding', 0],
|
||||||
|
'float lower bound' => ['body_font_height', 0.8],
|
||||||
|
'float upper bound as int' => ['body_font_height', 3],
|
||||||
|
'float on a step' => ['body_font_height', 1.7],
|
||||||
|
'float as digits' => ['body_header_height', '1.2'],
|
||||||
|
'float from int' => ['body_header_height', 1],
|
||||||
|
'font' => ['heading_font', 'Open Sans'],
|
||||||
|
'font with umlaut and padding' => ['body_font', " Fjalla Önö \t"],
|
||||||
|
'font of 100 characters' => ['primary_nav_font', str_repeat('a', 100)],
|
||||||
|
'weight as text' => ['heading_font_weight', '700'],
|
||||||
|
'weight as int' => ['body_font_weight', 400],
|
||||||
|
'style' => ['body_header_style', 'uppercase'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider validValues */
|
||||||
|
public function testCheckAcceptsValidValues(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
self::assertNull(ThemeOptionSpec::check($key, $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, mixed, string}> */
|
||||||
|
public static function invalidValues(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'int below' => ['body_font_size', 9, 'außerhalb von 10 bis 32'],
|
||||||
|
'int above' => ['body_font_size', 33, 'außerhalb von 10 bis 32'],
|
||||||
|
'int digits above' => ['content_width', '1921', 'außerhalb von 960 bis 1920'],
|
||||||
|
'negative below' => ['body_header_spacing', -3, 'außerhalb von -2 bis 10'],
|
||||||
|
'float with a fraction for an int key' => ['body_font_size', 15.5, 'ganze Zahl'],
|
||||||
|
'digits with a fraction for an int key' => ['row_padding', '2.5', 'ganze Zahl'],
|
||||||
|
'non-numeric text' => ['body_font_size', 'groß', 'erwartet eine Zahl, erhalten "groß"'],
|
||||||
|
'unit suffix' => ['body_font_size', '15px', 'erwartet eine Zahl'],
|
||||||
|
'exponent' => ['body_font_size', '1e1', 'erwartet eine Zahl'],
|
||||||
|
'spaces around digits' => ['body_font_size', ' 15', 'erwartet eine Zahl'],
|
||||||
|
'empty text' => ['gutter_width', '', 'erwartet eine Zahl'],
|
||||||
|
'true' => ['body_font_size', true, 'erhalten true'],
|
||||||
|
'false' => ['body_font_size', false, 'erhalten false'],
|
||||||
|
'null' => ['body_font_size', null, 'erhalten null'],
|
||||||
|
'array' => ['body_font_size', [14], 'erhalten einen array'],
|
||||||
|
'infinity' => ['body_font_height', INF, 'erwartet eine Zahl'],
|
||||||
|
'not a number' => ['body_font_height', NAN, 'erwartet eine Zahl'],
|
||||||
|
'float below' => ['body_font_height', 0.7, 'außerhalb von 0,8 bis 3'],
|
||||||
|
'float above' => ['body_header_height', 3.1, 'außerhalb von 0,8 bis 3'],
|
||||||
|
'float off the step' => ['body_font_height', 1.75, 'Schrittweite 0,1 (ab 0,8)'],
|
||||||
|
'float digits off the step' => ['body_header_height', '1.05', 'Schrittweite'],
|
||||||
|
'font as number' => ['heading_font', 12, 'erwartet einen Schriftnamen'],
|
||||||
|
'font as false' => ['heading_font', false, 'erwartet einen Schriftnamen'],
|
||||||
|
'font as null' => ['heading_font', null, 'erhalten null'],
|
||||||
|
'empty font' => ['body_font', '', '1 bis 100 Zeichen'],
|
||||||
|
'blank font' => ['body_font', " \t ", '1 bis 100 Zeichen'],
|
||||||
|
'font of 101 characters' => ['body_font', str_repeat('a', 101), '1 bis 100 Zeichen'],
|
||||||
|
'font with a control character' => ['body_font', "Open\nSans", 'unzulässige Zeichen'],
|
||||||
|
'font with angle brackets' => ['body_font', 'Open<Sans>', 'unzulässige Zeichen'],
|
||||||
|
'font with a quote' => ['primary_nav_font', 'Open "Sans"', 'unzulässige Zeichen'],
|
||||||
|
'font with an apostrophe' => ['secondary_nav_font', "Open'Sans", 'unzulässige Zeichen'],
|
||||||
|
'font with invalid utf-8' => ['body_font', "Open\xC3(Sans", 'unzulässige Zeichen'],
|
||||||
|
'font none' => ['heading_font', 'None', 'null setzt auf Divis Standardschrift zurück'],
|
||||||
|
'weight off the list' => ['body_font_weight', '550', 'erlaubt ist genau ein Wert aus 100, 200'],
|
||||||
|
'weight as float' => ['body_font_weight', 500.0, 'erhalten 500'],
|
||||||
|
'weight word' => ['heading_font_weight', 'bold', 'erhalten "bold"'],
|
||||||
|
'weight as null' => ['heading_font_weight', null, 'erhalten null'],
|
||||||
|
'style combination' => ['body_header_style', 'italic|uppercase', 'genau ein Wert'],
|
||||||
|
'style empty' => ['body_header_style', '', 'genau ein Wert'],
|
||||||
|
'style bold' => ['body_header_style', 'bold', 'genau ein Wert'],
|
||||||
|
'style case' => ['body_header_style', 'Italic', 'genau ein Wert'],
|
||||||
|
'style as list' => ['body_header_style', ['italic'], 'erhalten einen array'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider invalidValues */
|
||||||
|
public function testCheckRejectsInvalidValuesWithAGermanMessage(string $key, mixed $value, string $needle): void
|
||||||
|
{
|
||||||
|
$message = ThemeOptionSpec::check($key, $value);
|
||||||
|
|
||||||
|
self::assertIsString($message);
|
||||||
|
self::assertStringContainsString($needle, $message);
|
||||||
|
self::assertStringStartsWith(ThemeOptionSpec::definition($key)['label'] . ':', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string}> */
|
||||||
|
public static function everyKey(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (string $key): array => [$key], array_combine(
|
||||||
|
ThemeOptionSpec::KEYS,
|
||||||
|
ThemeOptionSpec::KEYS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The adapter's sanitizers let booleans through is_scalar (absint(true) = 1), so check() must stop them.
|
||||||
|
*
|
||||||
|
* @dataProvider everyKey
|
||||||
|
*/
|
||||||
|
public function testCheckRejectsBooleansNullArraysAndObjectsForEveryKey(string $key): void
|
||||||
|
{
|
||||||
|
foreach ([true, false, null, [], [1], new \stdClass()] as $value) {
|
||||||
|
self::assertIsString(ThemeOptionSpec::check($key, $value), $key . ' accepted ' . get_debug_type($value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCheckOfAnUnknownKeyThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
ThemeOptionSpec::check('logo', 'x');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNormalizeCastsToTheTypeOfTheKey(): void
|
||||||
|
{
|
||||||
|
self::assertSame(15, ThemeOptionSpec::normalize('body_font_size', '15'));
|
||||||
|
self::assertSame(15, ThemeOptionSpec::normalize('body_font_size', 15.0));
|
||||||
|
self::assertSame(-1, ThemeOptionSpec::normalize('body_header_spacing', '-1'));
|
||||||
|
self::assertSame(1.7, ThemeOptionSpec::normalize('body_font_height', '1.7'));
|
||||||
|
self::assertSame(1.0, ThemeOptionSpec::normalize('body_header_height', 1));
|
||||||
|
self::assertSame('700', ThemeOptionSpec::normalize('heading_font_weight', 700));
|
||||||
|
self::assertSame('400', ThemeOptionSpec::normalize('body_font_weight', '400'));
|
||||||
|
self::assertSame('Open Sans', ThemeOptionSpec::normalize('heading_font', " Open Sans\t"));
|
||||||
|
self::assertSame('italic', ThemeOptionSpec::normalize('body_header_style', 'italic'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEffectiveFallsBackToTheDivisDefaultsWhenNothingIsStored(): void
|
||||||
|
{
|
||||||
|
self::assertSame(14, ThemeOptionSpec::effective('body_font_size', []));
|
||||||
|
self::assertSame(1.7, ThemeOptionSpec::effective('body_font_height', []));
|
||||||
|
self::assertSame('500', ThemeOptionSpec::effective('heading_font_weight', []));
|
||||||
|
self::assertSame(1080, ThemeOptionSpec::effective('content_width', []));
|
||||||
|
self::assertNull(ThemeOptionSpec::effective('heading_font', []));
|
||||||
|
self::assertNull(ThemeOptionSpec::effective('body_header_style', []));
|
||||||
|
self::assertSame(14, ThemeOptionSpec::effective('phone_body_font_size', []));
|
||||||
|
self::assertSame(30, ThemeOptionSpec::effective('phone_header_font_size', []));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEffectiveInheritsTheNextLargerSizeWhileUnset(): void
|
||||||
|
{
|
||||||
|
$stored = ['body_font_size' => 18, 'body_header_size' => 40];
|
||||||
|
|
||||||
|
self::assertSame(18, ThemeOptionSpec::effective('tablet_body_font_size', $stored));
|
||||||
|
self::assertSame(18, ThemeOptionSpec::effective('phone_body_font_size', $stored));
|
||||||
|
self::assertSame(40, ThemeOptionSpec::effective('tablet_header_font_size', $stored));
|
||||||
|
self::assertSame(40, ThemeOptionSpec::effective('phone_header_font_size', $stored));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAStoredOverrideBreaksTheChainAtItsLevelOnly(): void
|
||||||
|
{
|
||||||
|
$stored = ['body_font_size' => 18, 'tablet_body_font_size' => 16, 'tablet_header_font_size' => 28];
|
||||||
|
|
||||||
|
self::assertSame(18, ThemeOptionSpec::effective('body_font_size', $stored));
|
||||||
|
self::assertSame(16, ThemeOptionSpec::effective('tablet_body_font_size', $stored));
|
||||||
|
self::assertSame(16, ThemeOptionSpec::effective('phone_body_font_size', $stored));
|
||||||
|
self::assertSame(28, ThemeOptionSpec::effective('phone_header_font_size', $stored));
|
||||||
|
self::assertSame(30, ThemeOptionSpec::effective('body_header_size', $stored));
|
||||||
|
$stored['phone_body_font_size'] = '12';
|
||||||
|
self::assertSame(12, ThemeOptionSpec::effective('phone_body_font_size', $stored));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEffectiveTreatsFalseAndGarbageAsUnsetAndCastsStoredDigits(): void
|
||||||
|
{
|
||||||
|
self::assertNull(ThemeOptionSpec::effective('heading_font', ['heading_font' => false]));
|
||||||
|
self::assertNull(ThemeOptionSpec::effective('heading_font', ['heading_font' => 'none']));
|
||||||
|
self::assertSame('Lato', ThemeOptionSpec::effective('heading_font', ['heading_font' => 'Lato']));
|
||||||
|
self::assertSame(14, ThemeOptionSpec::effective('body_font_size', ['body_font_size' => 'groß']));
|
||||||
|
self::assertSame(15, ThemeOptionSpec::effective('body_font_size', ['body_font_size' => '15']));
|
||||||
|
self::assertSame(1.2, ThemeOptionSpec::effective('body_font_height', ['body_font_height' => '1.2']));
|
||||||
|
self::assertSame('300', ThemeOptionSpec::effective('body_font_weight', ['body_font_weight' => 300]));
|
||||||
|
self::assertSame(40, ThemeOptionSpec::effective('body_font_size', ['body_font_size' => 40]), 'no range check');
|
||||||
|
self::assertSame(18, ThemeOptionSpec::effective('tablet_body_font_size', [
|
||||||
|
'body_font_size' => 18, 'tablet_body_font_size' => false,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEffectiveOfAnUnknownKeyThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
ThemeOptionSpec::effective('nope', []);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user