feat: lit/divi-theme-options-update ändert Theme-Optionen über DesignChange (Prüfung, Divi-Sanitizer, Schriftvorschläge)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 17:51:50 +02:00
co-authored by Claude Sonnet 5.5
parent 122548db12
commit 879ca752e7
4 changed files with 657 additions and 0 deletions
@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-theme-options-update (spec §8.1, rulings W2–W5, W7): sets or resets the whitelisted Divi theme options
* through the change pipeline (ThemeOptionsArea). set maps keys to values; null resets a key to Divi's default
* (et_delete_option), "none" and false are refused. Every value passes ThemeOptionSpec::check() (type, range, choice),
* fonts must be known to Divi exactly (FontCheck, with suggestions), and Divi's own sanitizer must keep the value –
* false or a changed value is an error, since Divi would store false or round silently; the value is stored with the
* sanitizer's type. A tablet/phone size equal to the size it inherits stays unset. All problems are collected with
* the path set.<key>. Theme options have no page reference: the dry run warns about the whole website instead.
*/
final class DiviThemeOptionsUpdateAbility implements Ability, RequiresDiviFeatures
{
private const RESET_HINT = 'zum Zurücksetzen den Wert null angeben';
private const BUILDER = 'Geänderte Schriften wirken auch im Divi-Builder (Standardschrift der Module).';
public function __construct(
private DesignSystemWriter $writer,
private ThemeOptionsAdapter $options,
private FontCatalog $fonts,
private DesignChange $change
) {
}
public function name(): string
{
return 'lit/divi-theme-options-update';
}
public function label(): string
{
return 'Divi-Theme-Optionen ändern';
}
public function description(): string
{
return 'Ändert Divis Theme-Optionen für Schriften, Schriftgrößen und Layout. set: {Schlüssel: Wert}; null '
. 'setzt auf den Divi-Standard zurück. Schlüssel, aktuelle Werte und Bereiche liefert '
. 'lit/divi-theme-options-get; Schriften mit dem exakten Namen einer Divi-Schrift (unbekannte Namen '
. 'bekommen Vorschläge). Die Hauptfarben ändert lit/divi-colors-set. Wirkt auf die gesamte Website: zuerst '
. 'Probelauf (diff, Warnungen, design_rev), nach Rückfrage beim Nutzer confirm: true mit design_rev. '
. 'Vorher wird ein Snapshot angelegt (lit/divi-design-restore).';
}
public function inputSchema(): array
{
$keys = [];
foreach (ThemeOptionSpec::KEYS as $key) {
$keys[$key] = self::keySchema($key);
}
return [
'type' => 'object',
'properties' => [
'set' => [
'type' => 'object',
'minProperties' => 1,
'properties' => $keys,
'additionalProperties' => false,
'description' => 'Theme-Option => neuer Wert; null = Divi-Standard.',
],
] + DesignChange::inputProperties(),
'required' => ['set'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return DesignChange::outputSchema();
}
public function annotations(): array
{
return Annotations::destructive(false);
}
public function isDirect(): bool
{
return false;
}
/** Reads et_divi and the Customizer table (ThemeOptionRead); sanitizer, font list and writing: ThemeOptionWrite. */
public function diviFeatures(array $input): array
{
return [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite];
}
public function permission(array $input): bool|WP_Error
{
return DesignRights::themeOptions($this->writer, $this->options) ?? true;
}
public function execute(array $input): mixed
{
$refusal = DesignRights::themeOptions($this->writer, $this->options);
if ($refusal !== null) {
return $refusal;
}
$set = $input['set'] ?? null;
if (! is_array($set) || $set === [] || array_is_list($set)) {
return Errors::invalidInput(
'set fehlt oder ist leer; nichts wurde gespeichert.',
['set' => 'Objekt {Theme-Option: Wert}, Schlüssel aus lit/divi-theme-options-get']
);
}
return $this->change->run(
$this->name(),
new ThemeOptionsArea($this->options),
$input,
fn (array $raw): DesignPlan|WP_Error => $this->plan(ThemeOptionBook::fromDivi($raw), $set)
);
}
/** @param array<array-key, mixed> $set */
private function plan(ThemeOptionBook $before, array $set): DesignPlan|WP_Error
{
$errors = [];
ItemKeys::refuseUnknown($set, ThemeOptionSpec::KEYS, 'set', $errors, self::RESET_HINT);
$book = $before;
// In the fixed key order, so a tablet size is compared with the desktop size of this call.
foreach (ThemeOptionSpec::KEYS as $key) {
if (! array_key_exists($key, $set)) {
continue;
}
if ($set[$key] === null) {
$book = $book->without($key);
continue;
}
$value = $this->value($key, $set[$key], $errors);
if ($value !== null && ! self::inherited($book, $key, $value)) {
$book = $book->with($key, $value);
}
}
if ($errors !== []) {
return Errors::invalidInput('Ungültige Theme-Optionen; nichts wurde gespeichert.', $errors);
}
$diff = ThemeOptionDiff::between($before, $book);
return new DesignPlan($book->data(), $diff, ThemeOptionDiff::summary($diff), null, self::warnings($diff));
}
/**
* The value Divi stores for $key, or null after noting the problem in $errors.
*
* @param array<string, string> $errors
*/
private function value(string $key, mixed $value, array &$errors): int|float|string|null
{
$field = 'set.' . $key;
if ($value === false || $value === 'none') {
$errors[$field] = 'für den Divi-Standard null angeben, nicht false oder "none"';
return null;
}
$problem = ThemeOptionSpec::check($key, $value);
if ($problem === null && ThemeOptionSpec::definition($key)['type'] === 'font') {
// Before Divi's sanitizer: it would only answer false, without suggestions.
$problem = FontCheck::problem($this->fonts, (string) $value);
}
if ($problem !== null) {
$errors[$field] = $problem;
return null;
}
$normalized = ThemeOptionSpec::normalize($key, $value);
$sanitized = $this->options->sanitize($key, $normalized);
if (! is_int($sanitized) && ! is_float($sanitized) && ! is_string($sanitized)) {
$errors[$field] = 'Divi lehnt den Wert ab';
return null;
}
if (! ThemeOptionsArea::same($sanitized, $normalized)) {
$errors[$field] = sprintf('Divi würde den Wert verändert speichern (%s)', $sanitized);
return null;
}
return $sanitized;
}
/** Ruling W3: an unset tablet/phone size given the size it inherits stays unset – it keeps following that size. */
private static function inherited(ThemeOptionBook $book, string $key, int|float|string $value): bool
{
return isset(ThemeOptionSpec::definition($key)['inherits']) && ! $book->has($key)
&& ThemeOptionsArea::same(ThemeOptionSpec::effective($key, $book->data()), $value);
}
/**
* Ruling W7: no affected pages, but the reach of the change.
*
* @param list<array<string, mixed>> $diff
* @return list<string>
*/
private static function warnings(array $diff): array
{
if ($diff === []) {
return [];
}
foreach ($diff as $row) {
if (ThemeOptionSpec::definition((string) $row['key'])['type'] === 'font') {
return [ThemeOptionsArea::SITE_WIDE, self::BUILDER];
}
}
return [ThemeOptionsArea::SITE_WIDE];
}
/** @return array<string, mixed> The schema of one key: its types, the range of numbers, a description. */
private static function keySchema(string $key): array
{
$definition = ThemeOptionSpec::definition($key);
$schema = ['type' => match ($definition['type']) {
'int' => ['integer', 'string', 'null'],
'float' => ['number', 'string', 'null'],
'weight' => ['string', 'integer', 'null'],
default => ['string', 'null'],
}];
if (isset($definition['min'], $definition['max'])) {
$schema['minimum'] = $definition['min'];
$schema['maximum'] = $definition['max'];
}
$schema['description'] = $definition['label'] . (isset($definition['choices'])
? ': ' . implode(', ', $definition['choices'])
: '');
return $schema;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
defined('ABSPATH') || exit;
/**
* Ruling W5: a font name is valid when Divi knows it exactly (FontCatalog); otherwise the message names up to five
* similar fonts (FontSuggest) or the upload tool. Shared by the theme options and the font variables (W12).
*/
final class FontCheck
{
/** @return string|null Null when $name is a font Divi knows, else a German message. */
public static function problem(FontCatalog $fonts, string $name): ?string
{
if ($fonts->has($name)) {
return null;
}
$similar = FontSuggest::similar($name, $fonts->names());
return sprintf('unbekannte Schrift „%s“', $name) . ($similar === []
? '; eigene Schriften lädt lit/divi-fonts-upload hoch'
: '; ähnlich: ' . implode(', ', $similar));
}
}
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsUpdateAbility;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionDiff;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class DiviThemeOptionsUpdateAbilityTest extends TestCase
{
/** @var array<string, int|float|string|false> What the adapter stores (ThemeOptionsAreaTest::adapter()). */
private array $stored = [];
/** @var list<array{0: array<string, int|float|string|false>, 1: list<string>}> */
private array $writes = [];
protected function setUp(): void
{
parent::setUp();
// DesignLock (final review I-5): the lock is always free here.
Functions\when('add_option')->justReturn(true);
Functions\when('delete_option')->justReturn(true);
Functions\when('current_user_can')->justReturn(true);
$this->stored = ThemeOptionsAreaTest::stored();
$this->writes = [];
}
/** @param (Closure(string, mixed): mixed)|null $sanitize Divi's sanitizer (default: keeps the value). */
private function ability(
?SnapshotStore $snapshots = null,
?Closure $sanitize = null,
bool $customize = true,
?ThemeOptionsAdapter $options = null
): DiviThemeOptionsUpdateAbility {
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('canUseVisualBuilder')->andReturn(true);
return new DiviThemeOptionsUpdateAbility(
$writer,
$options ?? ThemeOptionsAreaTest::adapter($this->stored, $this->writes, $sanitize, $customize),
FontCheckTest::catalog(),
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3))
);
}
/** A SnapshotStore that expects one snapshot of the stored options and the log ID 3 attached to it. */
private static function snapshots(): SnapshotStore
{
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')
->with(
'theme_options',
ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(),
'lit/divi-theme-options-update'
)
->andReturn('theme_options-1');
$snapshots->expects('attachLog')->with('theme_options', 'theme_options-1', 3);
return $snapshots;
}
/**
* Dry run, then confirm with its design_rev.
*
* @param array<string, mixed> $input
* @return array{0: array<string, mixed>, 1: mixed}
*/
private function confirmed(DiviThemeOptionsUpdateAbility $ability, array $input): array
{
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
return [$dry, $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']])];
}
public function testSchemaAndMeta(): void
{
$ability = $this->ability();
$properties = $ability->inputSchema()['properties'];
self::assertSame('lit/divi-theme-options-update', $ability->name());
self::assertFalse($ability->isDirect());
self::assertSame(Annotations::destructive(false), $ability->annotations());
self::assertSame([DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $ability->diviFeatures([]));
self::assertSame(ThemeOptionSpec::KEYS, array_keys($properties['set']['properties']));
self::assertFalse($properties['set']['additionalProperties']);
self::assertSame(10, $properties['set']['properties']['body_font_size']['minimum']);
self::assertSame(32, $properties['set']['properties']['body_font_size']['maximum']);
self::assertContains('null', $properties['set']['properties']['heading_font']['type']);
self::assertArrayHasKey('design_rev', $properties);
self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2));
self::assertStringContainsString('confirm: true mit design_rev', $ability->description());
self::assertStringContainsString('lit/divi-colors-set', $ability->description());
}
/** Spec §10, W14: edit_theme_options and Divi's role right "Theme-Anpassung", before anything is read. */
public function testNeedsEditThemeOptionsAndTheCustomizerRight(): void
{
$input = ['set' => ['body_font_size' => 18]];
self::assertToolError($this->ability(customize: false)->execute($input), 'forbidden');
self::assertToolError($this->ability(customize: false)->permission($input), 'forbidden');
Functions\when('current_user_can')->justReturn(false);
self::assertToolError($this->ability()->execute($input), 'forbidden');
self::assertToolError($this->ability()->permission($input), 'forbidden');
self::assertSame([], $this->writes);
}
public function testSetMustBeAnObjectWithAtLeastOneKey(): void
{
foreach ([[], ['set' => []], ['set' => ['body_font']], ['set' => 'body_font']] as $input) {
self::assertToolError($this->ability()->execute($input), 'validation_failed');
}
}
public function testDryRunThenConfirmWritesTheOptions(): void
{
$input = ['set' => ['heading_font' => 'Roboto', 'body_font_size' => '18']];
[$dry, $result] = $this->confirmed($this->ability(self::snapshots()), $input);
self::assertSame(
[['heading_font', 'added'], ['body_font_size', 'changed']],
array_map(static fn (array $row): array => [$row['key'], $row['action']], $dry['diff'])
);
self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->rev(), $dry['design_rev']);
self::assertStringContainsString(ThemeOptionDiff::summary($dry['diff']), $dry['message']);
self::assertSame([], $dry['affected'], 'theme options have no page reference (W7)');
self::assertSame(ThemeOptionsArea::SITE_WIDE, $dry['warnings'][0]);
self::assertStringContainsString('Builder', $dry['warnings'][1]);
self::assertSame('theme_options-1', $result['snapshot_id']);
self::assertSame(18, $this->stored['body_font_size'], 'stored as the number Divi keeps');
self::assertSame('Roboto', $this->stored['heading_font']);
self::assertSame(ThemeOptionBook::fromDivi($this->stored)->rev(), $result['design_rev']);
}
/** Only fonts reach the builder (W7). */
public function testSizesOnlyWarnAboutTheWholeWebsite(): void
{
$dry = self::assertToolError(
$this->ability()->execute(['set' => ['content_width' => 1200]]),
'confirm_required'
);
self::assertSame([ThemeOptionsArea::SITE_WIDE], $dry['warnings']);
}
/** Ruling W3: null resets to Divi's default – the key is deleted, not stored as "none" or false. */
public function testNullResetsToTheDiviDefault(): void
{
[$dry] = $this->confirmed($this->ability(self::snapshots()), ['set' => ['body_font_height' => null]]);
self::assertSame(['reset'], array_column($dry['diff'], 'action'));
self::assertContains('body_font_height', $this->writes[0][1]);
self::assertArrayNotHasKey('body_font_height', $this->stored);
}
public function testNoneAndFalseAreRefusedWithAHint(): void
{
$error = $this->ability()->execute(['set' => ['heading_font' => false, 'body_font' => 'none']]);
self::assertToolError($error, 'validation_failed');
$fields = self::errorFields($error);
self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields));
self::assertStringContainsString('null', $fields['set.body_font']);
}
public function testAllProblemsAreCollectedAndUnknownKeysRefused(): void
{
$error = $this->ability()->execute(['set' => [
'gutter_width' => 'breit',
'accent_color' => '#ffffff',
'delete' => true,
'body_font_size' => 99,
'body_header_style' => 'bold',
]]);
self::assertToolError($error, 'validation_failed');
$fields = self::errorFields($error);
self::assertSame(
['set.accent_color', 'set.delete', 'set.body_font_size', 'set.body_header_style', 'set.gutter_width'],
array_keys($fields)
);
self::assertSame('unbekanntes Feld', $fields['set.accent_color']);
self::assertStringContainsString('null', $fields['set.delete']);
self::assertSame([], $this->writes);
}
/** Ruling W5: the exact name of a Divi font; unknown names get suggestions before Divi's sanitizer is asked. */
public function testUnknownFontsGetSuggestions(): void
{
$asked = [];
$sanitize = static function (string $key, mixed $value) use (&$asked): mixed {
$asked[] = $key;
return $value;
};
$error = $this->ability(sanitize: $sanitize)->execute(['set' => [
'heading_font' => 'robotto',
'body_font' => 'Xyzzy Grotesk',
'primary_nav_font' => 'Lato',
]]);
self::assertToolError($error, 'validation_failed');
$fields = self::errorFields($error);
self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields));
self::assertStringContainsString('Roboto', $fields['set.heading_font']);
self::assertStringContainsString('lit/divi-fonts-upload', $fields['set.body_font']);
self::assertSame(['primary_nav_font'], $asked, 'only the known font goes to Divi');
}
/** Divi's sanitizer answers false or changes the value – Divi would store false or round (Global Constraints). */
public function testValuesDiviRejectsOrChangesAreErrors(): void
{
$sanitize = static fn (string $key, mixed $value): mixed => match ($key) {
'body_header_style' => false,
'content_width' => 1000,
default => $value,
};
$error = $this->ability(sanitize: $sanitize)->execute(['set' => [
'body_header_style' => 'italic',
'content_width' => 1200,
'row_padding' => 3,
]]);
self::assertToolError($error, 'validation_failed');
self::assertSame(['set.body_header_style', 'set.content_width'], array_keys(self::errorFields($error)));
}
/** Ruling W4: the value is stored with the type Divi's sanitizer returns. */
public function testTheSanitizedTypeIsStored(): void
{
$sanitize = static fn (string $key, mixed $value): mixed
=> $key === 'body_font_size' ? (string) $value : $value;
$this->confirmed($this->ability(self::snapshots(), $sanitize), ['set' => ['body_font_size' => 20]]);
self::assertSame('20', $this->stored['body_font_size']);
}
/** Ruling W3: a tablet/phone size equal to the size it inherits stays unset and keeps following it. */
public function testATabletSizeEqualToTheInheritedSizeIsNoChange(): void
{
$same = $this->ability()->execute(['set' => ['tablet_body_font_size' => 16]]);
self::assertSame([], $same['diff']);
self::assertStringStartsWith('Keine Änderung', $same['summary']);
$dry = self::assertToolError(
$this->ability()->execute(['set' => ['tablet_body_font_size' => 20, 'body_font_size' => 20]]),
'confirm_required'
);
self::assertSame(['body_font_size'], array_column($dry['diff'], 'key'), 'inherits the new desktop size');
$own = self::assertToolError(
$this->ability()->execute(['set' => ['tablet_body_font_size' => 15]]),
'confirm_required'
);
self::assertSame([['tablet_body_font_size', 'added']], array_map(
static fn (array $row): array => [$row['key'], $row['action']],
$own['diff']
));
self::assertSame([], $this->writes);
}
public function testUnchangedValuesAreNoChange(): void
{
$result = $this->ability()->execute(['set' => ['body_font_size' => 16, 'gutter_width' => null]]);
self::assertSame([], $result['diff']);
self::assertSame([], $this->writes);
}
public function testConfirmNeedsTheDesignRevOfTheDryRun(): void
{
$input = ['set' => ['body_font_size' => 18], 'confirm' => true];
self::assertToolError($this->ability()->execute($input), 'validation_failed');
self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch');
self::assertSame([], $this->writes);
}
public function testAFailedWriteIsRolledBackToTheSnapshot(): void
{
$calls = [];
$options = Mockery::mock(ThemeOptionsAdapter::class);
$options->allows('canCustomize')->andReturn(true);
$options->allows('read')->andReturn(ThemeOptionsAreaTest::stored());
$options->allows('sanitize')->andReturnUsing(static fn (string $key, mixed $value): mixed => $value);
$options->allows('write')->andReturnUsing(static function (array $set, array $unset) use (&$calls): array {
$calls[] = $set;
if (count($calls) === 1) {
throw new RuntimeException('Zurücklesen stimmt nicht');
}
return $set;
});
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('theme_options-1');
try {
$this->confirmed($this->ability($snapshots, options: $options), ['set' => ['body_font_size' => 18]]);
self::fail('expected WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertSame('theme_options-1', $e->details['snapshot_id']);
}
self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(), $calls[1]);
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Design\FontCheck;
use LIT\MCP\Integrations\Divi\Design\FontSuggest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
final class FontCheckTest extends TestCase
{
/** The names FontCatalog::names() delivers in these tests. */
public const NAMES = ['Lato', 'Open Sans', 'Roboto', 'Roboto Mono', 'Roboto Slab'];
/** A FontCatalog over NAMES: has() is exact, like Divi's sanitizer (ruling W5). */
public static function catalog(): FontCatalog&MockInterface
{
$fonts = Mockery::mock(FontCatalog::class);
$fonts->allows('names')->andReturn(self::NAMES);
$fonts->allows('has')->andReturnUsing(static fn (string $name): bool => in_array($name, self::NAMES, true));
return $fonts;
}
public function testAKnownFontIsFine(): void
{
$fonts = self::catalog();
$fonts->shouldNotReceive('names');
self::assertNull(FontCheck::problem($fonts, 'Roboto Mono'));
}
/** Ruling W5: the name is exact – "roboto" is unknown, but gets "Roboto" as suggestion. */
public function testAnUnknownFontGetsSimilarNames(): void
{
self::assertSame(
sprintf(
'unbekannte Schrift „roboto“; ähnlich: %s',
implode(', ', FontSuggest::similar('roboto', self::NAMES))
),
FontCheck::problem(self::catalog(), 'roboto')
);
self::assertStringContainsString('Roboto', (string) FontCheck::problem(self::catalog(), 'roboto'));
}
public function testWithoutSimilarNamesTheUploadToolIsNamed(): void
{
self::assertSame(
'unbekannte Schrift „Xyzzy Grotesk“; eigene Schriften lädt lit/divi-fonts-upload hoch',
FontCheck::problem(self::catalog(), 'Xyzzy Grotesk')
);
}
}