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:
Jan-Niclas Loosen
2026-10-19 17:47:35 +02:00
co-authored by Claude Sonnet 5.5
parent a90cd7f5bd
commit a9edd850c1
2 changed files with 572 additions and 0 deletions
@@ -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', []);
}
}