From a9edd850c1d7f1b408ef43f9ba3025f75f16a3e9 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 17:47:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ThemeOptionSpec=20=E2=80=93=20die=2020?= =?UTF-8?q?=20Theme-Optionen=20mit=20Pr=C3=BCfung,=20Normalisierung=20und?= =?UTF-8?q?=20Vererbung=20(W2=E2=80=93W4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../Divi/Design/ThemeOptionSpec.php | 282 +++++++++++++++++ .../Divi/Design/ThemeOptionSpecTest.php | 290 ++++++++++++++++++ 2 files changed, 572 insertions(+) create mode 100644 src/Integrations/Divi/Design/ThemeOptionSpec.php create mode 100644 tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php diff --git a/src/Integrations/Divi/Design/ThemeOptionSpec.php b/src/Integrations/Divi/Design/ThemeOptionSpec.php new file mode 100644 index 0000000..efeb8a6 --- /dev/null +++ b/src/Integrations/Divi/Design/ThemeOptionSpec.php @@ -0,0 +1,282 @@ + '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, 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 $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 $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 $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), + }; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php b/tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php new file mode 100644 index 0000000..2e7fc8d --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php @@ -0,0 +1,290 @@ + */ + 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 */ + 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 */ + 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', '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 */ + 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', []); + } +}