feat: Fähigkeiten theme-option-read/-write und font-upload; ThemeOptionsAdapter und FontCatalog (Manifest, Nachbau customizer-typography, Signatur-Referenzen) und DesignRights::themeOptions()/fonts()

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 17:44:57 +02:00
co-authored by Claude Sonnet 5.5
parent 8379c55c89
commit a90cd7f5bd
14 changed files with 4765 additions and 26 deletions
+60 -9
View File
@@ -13,11 +13,15 @@ defined('ABSPATH') || exit;
/**
* Who may change the design system (spec §10): presets and colors need edit_theme_options and Divi's role right
* "Visual Builder verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1"); variables
* also the role right "Variables Manager". Without unfiltered_html a write is refused while presets it saves along
* hold HTML/CSS that Divi would strip (final review I-2).
* also the role right "Variables Manager". Theme options need edit_theme_options and the role right
* "Theme-Anpassung" (what Divi's customizer asks), font uploads upload_files, edit_theme_options and the role right
* "Eigene Schriften verwalten" (Divi's font route plus the plugin's rule, plan 4d). Without unfiltered_html a write is
* refused while presets it saves along hold HTML/CSS that Divi would strip (final review I-2).
*/
final class DesignRights
{
private const ROLE_EDITOR = 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.';
public const OTHER_MARKUP = 'Andere Presets enthalten HTML/CSS, das beim Speichern ohne das Recht unfiltered_html '
. 'verloren ginge; nichts wurde gespeichert.';
@@ -70,18 +74,52 @@ final class DesignRights
return Errors::forbidden(
'Die Divi-Rolle dieses Kontos erlaubt den Variablen-Manager nicht; ohne ihn speichert Divi keine '
. 'Variablen.',
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
self::ROLE_EDITOR
);
}
/**
* Theme options (writing and restoring): edit_theme_options and Divi's role right "Theme-Anpassung". $writer is
* not asked – Divi's customizer needs no Visual Builder; it keeps the signature of the other design rights.
*/
public static function themeOptions(DesignSystemWriter $writer, ThemeOptionsAdapter $options): ?WP_Error
{
$refusal = self::themeRight('Theme-Optionen ändern');
if ($refusal !== null || $options->canCustomize()) {
return $refusal;
}
return Errors::forbidden(
'Die Divi-Rolle dieses Kontos erlaubt die Theme-Anpassung nicht; ohne sie ändert LIT MCP keine '
. 'Theme-Optionen.',
self::ROLE_EDITOR
);
}
/** Font uploads: upload_files, edit_theme_options and Divi's role right "Eigene Schriften verwalten". */
public static function fonts(FontCatalog $fonts): ?WP_Error
{
if (! current_user_can('upload_files')) {
return Errors::forbidden('Schriften hochladen erfordert das Recht, Dateien hochzuladen (upload_files).');
}
$refusal = self::themeRight('Schriften hochladen');
if ($refusal !== null || $fonts->canManage()) {
return $refusal;
}
return Errors::forbidden(
'Die Divi-Rolle dieses Kontos erlaubt „Eigene Schriften verwalten“ nicht; ohne das Recht lädt LIT MCP '
. 'keine Schriften hoch.',
self::ROLE_EDITOR
);
}
/** edit_theme_options and Divi's role right "Visual Builder verwenden"; $what names the area in the message. */
private static function designer(DesignSystemWriter $writer, string $what): ?WP_Error
{
if (! current_user_can('edit_theme_options')) {
return Errors::forbidden(sprintf(
'%s ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).',
$what
));
$refusal = self::themeRight($what . ' ändern');
if ($refusal !== null) {
return $refusal;
}
if (! $writer->canUseVisualBuilder()) {
return Errors::forbidden(
@@ -89,10 +127,23 @@ final class DesignRights
'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine %s.',
$what
),
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
self::ROLE_EDITOR
);
}
return null;
}
/** edit_theme_options; $activity starts the message ("Presets ändern", "Schriften hochladen"). */
private static function themeRight(string $activity): ?WP_Error
{
if (current_user_can('edit_theme_options')) {
return null;
}
return Errors::forbidden(sprintf(
'%s erfordert das Recht, das Design der Website anzupassen (edit_theme_options).',
$activity
));
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest;
defined('ABSPATH') || exit;
/**
* Adapter "Designsystem" (spec §3) for Divi's font lists (plan 4d W5, W9): the families Divi's customizer sanitizer
* accepts (uploaded fonts + et_builder_get_fonts(), exact names), the uploaded ones, and every reserved name for an
* upload, plus Divi's role right "Eigene Schriften verwalten". et_builder_get_google_fonts() parses about 1.3 MB and
* may fetch from Google once a day, so names() is loaded once per instance – DiviIntegration builds one per request;
* uploaded() and reserved() are read fresh (an upload changes them). Not final so the tools can be tested with a mock.
*/
class FontCatalog implements DiviAdapter
{
/** @var list<string>|null */
private ?array $names = null;
public static function manifest(): Manifest
{
return new Manifest(
Manifest::DESIGN,
calls: [
new Call('et_builder_get_fonts', 0),
new Call('et_builder_get_custom_fonts', 0),
new Call('et_builder_get_google_fonts', 0),
new Call('et_builder_get_websafe_fonts', 0),
new Call('et_pb_is_allowed', 1, [DiviFeature::FontUpload]),
],
// The options behind Divi's lists; read only through the functions above.
keys: ['et_uploaded_fonts', 'et_google_fonts_cache', 'et_google_api_settings'],
// Plan 4d W1: font variables are checked against the same list, so a break locks all three on purpose.
features: [DiviFeature::ThemeOptionWrite, DiviFeature::FontUpload, DiviFeature::VariableWrite]
);
}
/** @return list<string> Exact family names Divi's sanitizer accepts: uploaded, then web-safe and Google. */
public function names(): array
{
return $this->names ??= self::namesOf(et_builder_get_custom_fonts(), et_builder_get_fonts());
}
/** @return list<string> */
public function uploaded(): array
{
return self::namesOf(et_builder_get_custom_fonts());
}
/** @return list<string> Google, web-safe and uploaded names – an upload must not reuse one (W9). */
public function reserved(): array
{
return self::namesOf(
et_builder_get_google_fonts(),
et_builder_get_websafe_fonts(),
et_builder_get_custom_fonts()
);
}
public function has(string $name): bool
{
return in_array($name, $this->names(), true);
}
/** Divi's role right "Eigene Schriften verwalten" (custom_fonts_management); fail closed without Divi. */
public function canManage(): bool
{
return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed('custom_fonts_management');
}
/**
* @param mixed ...$lists Divi's lists (family => settings); anything else (a broken filter) is skipped.
* @return list<string>
*/
private static function namesOf(mixed ...$lists): array
{
$names = [];
foreach ($lists as $list) {
if (! is_array($list)) {
continue;
}
foreach (array_keys($list) as $name) {
$names[] = (string) $name;
}
}
return array_values(array_unique($names));
}
}
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* Adapter "Designsystem" (spec §3) for the whitelisted theme options of Divi's customizer (plan 4d W2, W6): reads
* them raw from get_option('et_divi'), sends a value through the customizer's own sanitizer of its setting, and
* writes the way a customizer save ends – et_update_option()/et_delete_option() per key, then the static CSS and the
* builder caches (research A.2/A.3). Limits (W13): et_update_option() writes the whole et_divi array from Divi's
* request global, so a change another process made after this request loaded it is lost; design_rev and the lock of
* the pipeline narrow that window. remove_static_resources() does nothing without a logged-in user. Not final so the
* theme option area can be tested with a mock.
*/
class ThemeOptionsAdapter implements DiviAdapter
{
/** Divi's theme options (one array option). */
private const OPTIONS = 'et_divi';
/**
* The 20 whitelist keys (plan 4d W2, in this order) => the sanitize_callback of their setting in
* et_divi_customizer_theme_settings() (mirror customizer-typography). sanitize() calls it by this name; every
* Divi name here is a call of the manifest (ThemeOptionsAdapterTest), absint is WordPress core.
*/
public const SANITIZERS = [
'heading_font' => 'et_sanitize_font_choices',
'body_font' => 'et_sanitize_font_choices',
'primary_nav_font' => 'et_sanitize_font_choices',
'secondary_nav_font' => 'et_sanitize_font_choices',
'heading_font_weight' => 'et_sanitize_global_font_weight',
'body_font_weight' => 'et_sanitize_global_font_weight',
'body_font_size' => 'absint',
'tablet_body_font_size' => 'absint',
'phone_body_font_size' => 'absint',
'body_header_size' => 'absint',
'tablet_header_font_size' => 'absint',
'phone_header_font_size' => 'absint',
'body_font_height' => 'et_sanitize_float_number',
'body_header_height' => 'et_sanitize_float_number',
'body_header_spacing' => 'et_sanitize_int_number',
'body_header_style' => 'et_divi_global_font_style',
'content_width' => 'absint',
'gutter_width' => 'absint',
'section_padding' => 'absint',
'row_padding' => 'absint',
];
public static function manifest(): Manifest
{
return new Manifest(
Manifest::DESIGN,
calls: [
new Call('et_sanitize_font_choices', 1),
new Call('et_sanitize_global_font_weight', 1),
new Call('et_sanitize_float_number', 1),
new Call('et_sanitize_int_number', 1),
new Call('et_divi_global_font_style', 1),
new Call('et_update_option', 2),
new Call('et_delete_option', 1),
new Call('ET_Core_PageResource::remove_static_resources', 2),
new Call('et_pb_force_regenerate_templates', 0),
new Call('et_pb_is_allowed', 1),
],
keys: [self::OPTIONS],
mirrors: [
// shortcut: the fingerprint covers the whole customizer function (about 4,000 lines) and changes with
// every customizer edit; switch to a narrower excerpt when it fires too often.
new Mirror(
'customizer-typography',
'functions.php',
'et_divi_customizer_theme_settings',
null,
'Standards, Bereiche und Sanitizer der 20 Theme-Optionen (ThemeOptionSpec)',
[DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite]
),
],
features: [DiviFeature::ThemeOptionWrite]
);
}
/**
* The whitelist keys present in et_divi with their raw value, in the order of SANITIZERS; a missing key (Divi's
* default) is missing here too. A value of another type (array, null, true) is no customizer value and left out.
*
* @return array<string, int|float|string|false>
*/
public function read(): array
{
$options = get_option(self::OPTIONS);
if (! is_array($options)) {
return [];
}
$stored = [];
foreach (array_keys(self::SANITIZERS) as $key) {
$value = $options[$key] ?? null;
if (is_int($value) || is_float($value) || is_string($value) || $value === false) {
$stored[$key] = $value;
}
}
return $stored;
}
/**
* Divi's customizer sanitizer of $key applied to $value; false = Divi refuses it (Divi itself would store false).
* Non-scalars never reach Divi's sanitizers.
*
* @throws InvalidArgumentException When $key is not a whitelist key.
*/
public function sanitize(string $key, mixed $value): mixed
{
$sanitizer = self::SANITIZERS[$key] ?? throw new InvalidArgumentException(
'not a theme option of the whitelist: ' . $key
);
return is_scalar($value) ? $sanitizer($value) : false;
}
/**
* Writes like a customizer save: $set per key with et_update_option(), $unset (back to Divi's default) with
* et_delete_option(), then the static CSS and the builder caches; reads back from et_divi.
*
* @param array<string, int|float|string|false> $set Checked values (ThemeOptionSpec, sanitize()) or raw values
* of a snapshot.
* @param list<string> $unset
* @return array<string, int|float|string|false> read() after writing.
* @throws DiviLocked When theme-option-write is not released – before anything is written.
* @throws InvalidArgumentException When a key is not a whitelist key – before anything is written.
* @throws RuntimeException When the read-back differs from what was written.
*/
public function write(array $set, array $unset): array
{
ReleaseGate::assertFeature(DiviFeature::ThemeOptionWrite);
$unknown = array_diff([...array_keys($set), ...$unset], array_keys(self::SANITIZERS));
if ($unknown !== []) {
throw new InvalidArgumentException('not a theme option of the whitelist: ' . implode(', ', $unknown));
}
foreach ($set as $key => $value) {
et_update_option($key, $value);
}
foreach ($unset as $key) {
et_delete_option($key);
}
// What Divi runs on customize_save_after (research A.2): the customizer CSS lives in the static resources.
\ET_Core_PageResource::remove_static_resources('all', 'all');
et_pb_force_regenerate_templates();
$stored = $this->read();
$wrong = [];
foreach ($set as $key => $value) {
if (! array_key_exists($key, $stored) || $stored[$key] !== $value) {
$wrong[] = $key;
}
}
foreach ($unset as $key) {
if (array_key_exists($key, $stored)) {
$wrong[] = $key;
}
}
if ($wrong !== []) {
throw new RuntimeException('theme options not written: ' . implode(', ', $wrong));
}
return $stored;
}
/** Divi's role right "Theme-Anpassung" (theme_customizer); fail closed without Divi. */
public function canCustomize(): bool
{
return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed('theme_customizer');
}
}
@@ -6,8 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
@@ -31,6 +33,8 @@ final class DiviAdapters
DesignSystemWriter::class,
GlobalColorsAdapter::class,
GlobalVariablesAdapter::class,
ThemeOptionsAdapter::class,
FontCatalog::class,
DiviModules::class,
DynamicContentMap::class,
];
@@ -23,18 +23,21 @@ enum DiviFeature: string
case PresetWrite = 'preset-write';
case ColorWrite = 'color-write';
case VariableWrite = 'variable-write';
case ThemeOptionRead = 'theme-option-read';
case ThemeOptionWrite = 'theme-option-write';
case FontUpload = 'font-upload';
case LibraryRead = 'library-read';
case LibraryWrite = 'library-write';
case Roles = 'roles';
case ThemeBuilderRead = 'tb-read';
case ThemeBuilderWrite = 'tb-write';
/** Whether the feature changes data (the six *Write cases). */
/** Whether the feature changes data (the seven *Write cases and FontUpload). */
public function isWrite(): bool
{
return match ($this) {
self::PageWrite, self::PresetWrite, self::ColorWrite, self::VariableWrite, self::LibraryWrite,
self::ThemeBuilderWrite => true,
self::PageWrite, self::PresetWrite, self::ColorWrite, self::VariableWrite, self::ThemeOptionWrite,
self::FontUpload, self::LibraryWrite, self::ThemeBuilderWrite => true,
default => false,
};
}
@@ -51,6 +54,9 @@ enum DiviFeature: string
self::PresetWrite => 'Presets schreiben',
self::ColorWrite => 'Farben schreiben',
self::VariableWrite => 'Variablen schreiben',
self::ThemeOptionRead => 'Theme-Optionen lesen',
self::ThemeOptionWrite => 'Theme-Optionen schreiben',
self::FontUpload => 'Schriften hochladen',
self::LibraryRead => 'Bibliothek lesen',
self::LibraryWrite => 'Bibliothek schreiben',
self::Roles => 'Divi-Rollen',
@@ -1,4 +1,5 @@
{
"customizer-typography": "0264fa5a2ba604542e1e1ad0c0339f04c8f4f67297087a0f92954ee63d343fd0",
"library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02",
"preset-sync": "9586f8005b8065893c0350977a58ee9171bf715fff21ac0c84663cc512f45522",
"sig:ET\\Builder\\Framework\\UserRole\\UserRole::can_current_user_use_visual_builder": "76d199c13adc6600c8a456225a8eb9db8037064f20eca9db597c4e45ccecf8d5",
@@ -24,8 +25,19 @@
"sig:ET_Core_PageResource::remove_static_resources": "eaa39fd2c037c2d399578f71862da5c04b68d58a30657916482f95e94e5d8a96",
"sig:et_": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_builder_enabled_for_post_type": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_builder_get_custom_fonts": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_builder_get_fonts": "3ac3132305f3818c13df78416ae13e34c36af17a0ba9b66d652fe9babc6c051d",
"sig:et_builder_get_google_fonts": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_builder_get_public_post_types": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_builder_get_websafe_fonts": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_delete_option": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_divi_global_font_style": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_pb_force_regenerate_templates": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_pb_is_allowed": "b7561d91c3e696079a598474517118c3e9809cbf1f08b1321babe7af11d15799",
"sig:et_sanitize_float_number": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_sanitize_font_choices": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_sanitize_global_font_weight": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_sanitize_int_number": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_theme_builder_clear_wp_cache": "3ac3132305f3818c13df78416ae13e34c36af17a0ba9b66d652fe9babc6c051d",
"sig:et_theme_builder_get_flat_template_settings_options": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_theme_builder_get_template": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
@@ -33,6 +45,7 @@
"sig:et_theme_builder_get_theme_builder_template_ids": "b7561d91c3e696079a598474517118c3e9809cbf1f08b1321babe7af11d15799",
"sig:et_theme_builder_insert_layout": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_theme_builder_store_template": "6c84ec472739a2dbb87bcf21777c4dc977fd2233142eacfefe60eba462a4c219",
"sig:et_update_option": "b6b87f370cc0a0e01df5899074045bc8646b05855d4cd7103d9fc1ca0ac09799",
"tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4",
"tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d",
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -27,8 +27,8 @@ trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
check_pages() { wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count; }
# The ids of the thirteen features (plan decision D1, plan 4c V2), sorted.
FEATURE_IDS='["catalog","color-write","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write","variable-write"]'
# The ids of the sixteen features (plan decision D1, plan 4c V2, plan 4d W1), sorted.
FEATURE_IDS='["catalog","color-write","design-read","dynamic-sources","font-upload","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write","theme-option-read","theme-option-write","variable-write"]'
# Adds a problem to one feature in the cached state, as if its mirror check had failed. Needs the cache entry of
# ReleaseResolver (format 2: ["format" => 2, "key" => stateKey, "problems" => list of Problem::toArray()]); the key
@@ -68,7 +68,7 @@ echo "$STATUS" | jq -e --argjson ids "$FEATURE_IDS" '
.components[] | select(.id == "divi") | .features
| ([.[].id] | sort) == $ids
and all(.state == "released" and .source == "code" and .problems == [])' >/dev/null \
|| fail "all thirteen features must be released from code without problems: $STATUS"
|| fail "all sixteen features must be released from code without problems: $STATUS"
echo "$STATUS" | jq -e '
.components[] | select(.id == "divi") | .features
| (.[] | select(.id == "page-write") | .tools | any(. == "lit-divi-insert"))
@@ -76,7 +76,7 @@ echo "$STATUS" | jq -e '
and (.[] | select(.id == "preset-write") | .tools | any(startswith("lit/divi-presets-")))
and (.[] | select(.id == "page-read") | .label == "Seiten lesen")' >/dev/null \
|| fail "features must name their tools (direct lit-<name>, indirect lit/<name>): $STATUS"
pass "lit-site-status: thirteen features released (code), each with its tools"
pass "lit-site-status: sixteen features released (code), each with its tools"
# 2. Admin check through the REST route (application password instead of the cookie nonce).
# X-Forwarded-Proto: https is required here: without it the request to http://localhost with the
@@ -212,8 +212,8 @@ simulate_problem page-write "Smoke: Cache vergiftet"
CLI=$(wp --user="$ADMIN_LOGIN" lit-mcp divi-check 2>&1) || fail "wp lit-mcp divi-check must succeed (exit 0): $CLI"
echo "$CLI" | grep -q '^Fähigkeiten:' || fail "divi-check must print the matrix: $CLI"
echo "$CLI" | grep -q '✓ Seiten schreiben – freigegeben (code)' || fail "divi-check must show page-write released: $CLI"
[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "13" ] || fail "divi-check must show thirteen released features: $CLI"
[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "16" ] || fail "divi-check must show sixteen released features: $CLI"
[ "$(check_pages)" = "0" ] || fail "the CLI check must delete its page"
pass "wp lit-mcp divi-check prints thirteen released features, recomputes the state and leaves no page"
pass "wp lit-mcp divi-check prints sixteen released features, recomputes the state and leaves no page"
finish
@@ -228,14 +228,14 @@ final class DiviCheckCommandTest extends TestCase
self::assertSame(0, $code);
self::assertSame(
[
'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', 'tb-api-save',
'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags',
'customizer-typography', 'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed',
'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags',
],
array_keys(DiviReference::expected($this->expectedFile))
);
self::assertSame(str_repeat('b', 64), DiviReference::expected($this->expectedFile)['sig:A::b']);
self::assertContains(
'Referenz aktualisiert (6 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.',
'Referenz aktualisiert (7 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.',
$this->lines
);
self::assertStringStartsWith(
@@ -244,10 +244,11 @@ final class DiviCheckCommandTest extends TestCase
);
}
/** Minimal stand-ins for the six mirrored Divi functions at their real paths. */
/** Minimal stand-ins for the seven mirrored Divi functions at their real paths. */
private function writeDivi(): void
{
$files = [
'functions.php' => "function et_divi_customizer_theme_settings( \$wp_customize ) {\n}",
'includes/builder/shortcode-core.php' => "function et_pb_submit_layout( \$args ) {\n\treturn 1;\n}",
'includes/builder/frontend-builder/theme-builder/api.php' => "function et_theme_builder_api_save() {\n}",
'includes/builder/frontend-builder/theme-builder/theme-builder.php' =>
@@ -7,7 +7,9 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignRights;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
@@ -73,4 +75,46 @@ final class DesignRightsTest extends TestCase
$refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden');
self::assertStringContainsString('Variablen ändern erfordert', $refusal['message']);
}
public function testThemeOptionsNeedEditThemeOptionsAndTheThemeCustomizerRight(): void
{
$writer = Mockery::mock(DesignSystemWriter::class);
// Divi's customizer does not need the Visual Builder (plan 4d, rights).
$writer->expects('canUseVisualBuilder')->never();
$options = Mockery::mock(ThemeOptionsAdapter::class);
$options->allows('canCustomize')->andReturn(true, false);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
self::assertNull(DesignRights::themeOptions($writer, $options));
$refusal = self::assertToolError(DesignRights::themeOptions($writer, $options), 'forbidden');
self::assertStringContainsString('Theme-Anpassung', $refusal['message']);
self::assertStringContainsString('keine Theme-Optionen', $refusal['message']);
self::assertStringContainsString('Rollen-Editor', $refusal['hint']);
Functions\when('current_user_can')->justReturn(false);
$refusal = self::assertToolError(DesignRights::themeOptions($writer, $options), 'forbidden');
self::assertStringContainsString('Theme-Optionen ändern erfordert', $refusal['message']);
self::assertStringContainsString('edit_theme_options', $refusal['message']);
}
public function testFontsNeedUploadFilesEditThemeOptionsAndTheFontsRight(): void
{
$fonts = Mockery::mock(FontCatalog::class);
$fonts->allows('canManage')->andReturn(true, false);
Functions\when('current_user_can')->justReturn(true);
self::assertNull(DesignRights::fonts($fonts));
$refusal = self::assertToolError(DesignRights::fonts($fonts), 'forbidden');
self::assertStringContainsString('Eigene Schriften verwalten', $refusal['message']);
self::assertStringContainsString('Rollen-Editor', $refusal['hint']);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
$refusal = self::assertToolError(DesignRights::fonts($fonts), 'forbidden');
self::assertStringContainsString('upload_files', $refusal['message']);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'upload_files');
$refusal = self::assertToolError(DesignRights::fonts($fonts), 'forbidden');
self::assertStringContainsString('Schriften hochladen erfordert', $refusal['message']);
self::assertStringContainsString('edit_theme_options', $refusal['message']);
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Tests\Unit\TestCase;
/**
* Divi's font lists and et_pb_is_allowed() are faked with Brain Monkey; those tests run in their own process (Brain
* Monkey defines a missing function for the rest of the process, and other tests rely on Divi's functions being
* absent).
*/
final class FontCatalogTest extends TestCase
{
public function testIsARegisteredDesignAdapterForTheFontLists(): void
{
$manifest = FontCatalog::manifest();
self::assertContains(FontCatalog::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::DESIGN, $manifest->area);
self::assertSame(
[
'et_builder_get_fonts()', 'et_builder_get_custom_fonts()', 'et_builder_get_google_fonts()',
'et_builder_get_websafe_fonts()', 'et_pb_is_allowed()',
],
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
);
self::assertSame([0, 0, 0, 0, 1], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls));
self::assertSame(['et_uploaded_fonts', 'et_google_fonts_cache', 'et_google_api_settings'], $manifest->keys);
// Plan 4d W1: a broken font list locks every tool that checks font names against it.
self::assertSame(
[DiviFeature::ThemeOptionWrite, DiviFeature::FontUpload, DiviFeature::VariableWrite],
$manifest->features
);
self::assertSame([DiviFeature::FontUpload], $manifest->featuresOf($manifest->calls[4]));
}
/** @runInSeparateProcess */
public function testNamesAreTheExactFamiliesOfDivisSanitizerLoadedOnce(): void
{
Functions\expect('et_builder_get_custom_fonts')->once()->andReturn(['Hausschrift' => [], '1942' => []]);
Functions\expect('et_builder_get_fonts')->once()
->andReturn(['Arial' => [], 'Open Sans' => [], 'Roboto' => []]);
$catalog = new FontCatalog();
self::assertSame(['Hausschrift', '1942', 'Arial', 'Open Sans', 'Roboto'], $catalog->names());
self::assertTrue($catalog->has('Roboto'));
self::assertTrue($catalog->has('1942'), 'a numeric family name stays a string');
self::assertFalse($catalog->has('roboto'), 'exact, like Divi');
self::assertFalse($catalog->has('none'), 'Divi stores the default font as false, never as a name');
self::assertSame($catalog->names(), $catalog->names());
}
/** @runInSeparateProcess */
public function testUploadedAndReservedAreReadFreshEachTime(): void
{
Functions\expect('et_builder_get_custom_fonts')->twice()
->andReturn(['Hausschrift' => []], ['Hausschrift' => [], 'Neu' => []]);
Functions\when('et_builder_get_google_fonts')->justReturn(['Roboto' => []]);
Functions\when('et_builder_get_websafe_fonts')->justReturn(['Arial' => [], 'Roboto' => []]);
$catalog = new FontCatalog();
self::assertSame(['Hausschrift'], $catalog->uploaded());
self::assertSame(['Roboto', 'Arial', 'Hausschrift', 'Neu'], $catalog->reserved(), 'after an upload');
}
/** @runInSeparateProcess */
public function testABrokenListCountsAsEmpty(): void
{
// A filter of et_builder_google_fonts that returns no array (Divi passes it on unchecked).
Functions\when('et_builder_get_google_fonts')->justReturn(null);
Functions\when('et_builder_get_websafe_fonts')->justReturn(['Arial' => []]);
Functions\when('et_builder_get_custom_fonts')->justReturn([]);
self::assertSame(['Arial'], (new FontCatalog())->reserved());
}
public function testWithoutDiviNobodyManagesFonts(): void
{
// Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed".
self::assertFalse((new FontCatalog())->canManage());
}
/** @runInSeparateProcess */
public function testCanManageAsksTheCustomFontsRight(): void
{
Functions\when('et_pb_is_allowed')->alias(static fn (string $cap): bool => $cap === 'custom_fonts_management');
self::assertTrue((new FontCatalog())->canManage());
}
}
@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
/**
* Divi's sanitizers, et_update_option(), et_delete_option(), et_pb_force_regenerate_templates() and
* et_pb_is_allowed() are faked with Brain Monkey; those tests run in their own process (Brain Monkey defines a missing
* function for the rest of the process, and other tests rely on Divi's functions being absent). ET_Core_PageResource
* is the recording stub of tests/Stubs.
*/
final class ThemeOptionsAdapterTest extends TestCase
{
/** Plan 4d W2, in this order. */
private 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',
];
public function testIsARegisteredDesignAdapterForThemeOptions(): void
{
$manifest = ThemeOptionsAdapter::manifest();
self::assertContains(ThemeOptionsAdapter::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::DESIGN, $manifest->area);
self::assertSame(
[
'et_sanitize_font_choices()', 'et_sanitize_global_font_weight()', 'et_sanitize_float_number()',
'et_sanitize_int_number()', 'et_divi_global_font_style()', 'et_update_option()', 'et_delete_option()',
'ET_Core_PageResource::remove_static_resources()', 'et_pb_force_regenerate_templates()',
'et_pb_is_allowed()',
],
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
);
self::assertSame(
[1, 1, 1, 1, 1, 2, 1, 2, 0, 1],
array_map(static fn (Call $call): ?int => $call->args, $manifest->calls)
);
self::assertSame(['et_divi'], $manifest->keys);
self::assertSame([DiviFeature::ThemeOptionWrite], $manifest->features);
self::assertCount(1, $manifest->mirrors);
$mirror = $manifest->mirrors[0];
self::assertSame('customizer-typography', $mirror->id);
self::assertSame('functions.php', $mirror->file);
self::assertSame('et_divi_customizer_theme_settings', $mirror->function);
self::assertNull($mirror->class);
self::assertSame(
[DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite],
$manifest->featuresOf($mirror)
);
}
public function testTheSanitizerTableCoversTheWhitelistWithDivisCallsOfTheManifest(): void
{
self::assertSame(self::KEYS, array_keys(ThemeOptionsAdapter::SANITIZERS));
$divi = array_values(array_unique(array_diff(ThemeOptionsAdapter::SANITIZERS, ['absint'])));
sort($divi);
self::assertSame(
[
'et_divi_global_font_style', 'et_sanitize_float_number', 'et_sanitize_font_choices',
'et_sanitize_global_font_weight', 'et_sanitize_int_number',
],
$divi
);
$calls = array_map(
static fn (Call $call): string => $call->symbol . '/' . $call->args,
ThemeOptionsAdapter::manifest()->calls
);
foreach ($divi as $sanitizer) {
self::assertContains($sanitizer . '/1', $calls, 'every Divi sanitizer is a call of the manifest');
}
}
public function testSanitizeRefusesAKeyOutsideTheWhitelist(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('not a theme option of the whitelist: accent_color');
(new ThemeOptionsAdapter())->sanitize('accent_color', '#ff0000');
}
public function testSizesGoThroughWordPressAbsintAndNonScalarsAreRefused(): void
{
$adapter = new ThemeOptionsAdapter();
self::assertSame(15, $adapter->sanitize('body_font_size', '15.7'), 'absint rounds, the caller compares');
self::assertSame(1200, $adapter->sanitize('content_width', 1200));
self::assertFalse($adapter->sanitize('row_padding', ['2']));
}
/** @runInSeparateProcess */
public function testSanitizeCallsDivisCustomizerSanitizerOfTheKey(): void
{
Functions\expect('et_sanitize_font_choices')->once()->with('Roboto')->andReturn('Roboto');
Functions\expect('et_sanitize_global_font_weight')->once()->with('600')->andReturn('600');
Functions\expect('et_sanitize_float_number')->once()->with('1.8')->andReturn(1.8);
Functions\expect('et_sanitize_int_number')->once()->with('-1')->andReturn(-1);
Functions\expect('et_divi_global_font_style')->once()->with('italic|uppercase')->andReturn(false);
$adapter = new ThemeOptionsAdapter();
self::assertSame('Roboto', $adapter->sanitize('secondary_nav_font', 'Roboto'));
self::assertSame('600', $adapter->sanitize('body_font_weight', '600'));
self::assertSame(1.8, $adapter->sanitize('body_header_height', '1.8'));
self::assertSame(-1, $adapter->sanitize('body_header_spacing', '-1'));
self::assertFalse($adapter->sanitize('body_header_style', 'italic|uppercase'), 'Divi refuses combinations');
}
public function testReadReturnsThePresentWhitelistKeysWithTheirStoredTypes(): void
{
Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi'
? [
'row_padding' => null,
'accent_color' => '#ff0000',
'body_font' => 'Roboto',
'body_font_height' => 1.8,
'gutter_width' => ['3'],
'heading_font' => false,
'body_font_size' => 15,
]
: false);
self::assertSame(
['heading_font' => false, 'body_font' => 'Roboto', 'body_font_size' => 15, 'body_font_height' => 1.8],
(new ThemeOptionsAdapter())->read(),
'whitelist only, in W2 order; values of another type are not theme option values'
);
}
public function testReadWithoutThemeOptionsIsEmpty(): void
{
Functions\when('get_option')->justReturn(false);
self::assertSame([], (new ThemeOptionsAdapter())->read());
}
public function testWritesNeedAReleasedDivi(): void
{
ReleaseGate::useResolver(
static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0')
);
$this->expectException(DiviLocked::class);
(new ThemeOptionsAdapter())->write(['body_font_size' => 16], []);
}
public function testWriteRefusesAKeyOutsideTheWhitelistBeforeWriting(): void
{
// Unit tests have no et_update_option(): reaching it would be an Error, not this exception.
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('not a theme option of the whitelist: accent_color, divi_logo');
(new ThemeOptionsAdapter())->write(['body_font_size' => 16, 'accent_color' => '#f00'], ['divi_logo']);
}
/** @runInSeparateProcess */
public function testWriteUpdatesAndDeletesPerKeyClearsTheCachesAndReadsBack(): void
{
$stored = ['body_font_size' => 14, 'heading_font' => 'Roboto', 'accent_color' => '#ff0000'];
$log = [];
Functions\when('get_option')->alias(static function (string $name) use (&$stored): mixed {
return $name === 'et_divi' ? $stored : false;
});
Functions\when('et_update_option')->alias(static function (string $key, mixed $value) use (
&$stored,
&$log
): void {
$stored[$key] = $value;
$log[] = 'update ' . $key;
});
Functions\when('et_delete_option')->alias(static function (string $key) use (&$stored, &$log): void {
unset($stored[$key]);
$log[] = 'delete ' . $key;
});
Functions\when('et_pb_force_regenerate_templates')->alias(static function () use (&$log): void {
$log[] = 'regenerate templates after ' . count(\ET_Core_PageResource::$removed) . ' static CSS clear';
});
\ET_Core_PageResource::$removed = [];
$result = (new ThemeOptionsAdapter())->write(
['body_font_size' => 16, 'body_font_height' => 1.8],
['heading_font']
);
self::assertSame(['body_font_size' => 16, 'body_font_height' => 1.8], $result);
self::assertSame(
[
'update body_font_size', 'update body_font_height', 'delete heading_font',
'regenerate templates after 1 static CSS clear',
],
$log
);
self::assertSame([['all', 'all', false]], \ET_Core_PageResource::$removed, 'like a customizer save');
self::assertSame('#ff0000', $stored['accent_color'], 'other theme options stay');
}
/** @runInSeparateProcess */
public function testWriteThrowsWhenTheReadBackDiffers(): void
{
$stored = ['body_font_size' => 14, 'heading_font' => 'Roboto'];
Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi' ? $stored : false);
// Divi silently saves nothing (e.g. a filter or another process).
Functions\when('et_update_option')->justReturn(null);
Functions\when('et_delete_option')->justReturn(null);
Functions\when('et_pb_force_regenerate_templates')->justReturn(null);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('theme options not written: body_font_size, heading_font');
(new ThemeOptionsAdapter())->write(['body_font_size' => 16], ['heading_font']);
}
public function testWithoutDiviNobodyCustomizesTheTheme(): void
{
// Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed".
self::assertFalse((new ThemeOptionsAdapter())->canCustomize());
}
/** @runInSeparateProcess */
public function testCanCustomizeAsksTheThemeCustomizerRight(): void
{
Functions\when('et_pb_is_allowed')->alias(static fn (string $cap): bool => $cap === 'theme_customizer');
self::assertTrue((new ThemeOptionsAdapter())->canCustomize());
}
}
@@ -6,8 +6,10 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
@@ -82,6 +84,26 @@ final class DiviFeatureAssignmentTest extends TestCase
'GlobalData::set_global_variables()' => ['variable-write'],
'et_pb_is_allowed()' => ['variable-write'],
],
ThemeOptionsAdapter::class => [
'et_sanitize_font_choices()' => ['theme-option-write'],
'et_sanitize_global_font_weight()' => ['theme-option-write'],
'et_sanitize_float_number()' => ['theme-option-write'],
'et_sanitize_int_number()' => ['theme-option-write'],
'et_divi_global_font_style()' => ['theme-option-write'],
'et_update_option()' => ['theme-option-write'],
'et_delete_option()' => ['theme-option-write'],
'ET_Core_PageResource::remove_static_resources()' => ['theme-option-write'],
'et_pb_force_regenerate_templates()' => ['theme-option-write'],
'et_pb_is_allowed()' => ['theme-option-write'],
'Nachbau et_divi_customizer_theme_settings()' => ['theme-option-read', 'theme-option-write'],
],
FontCatalog::class => [
'et_builder_get_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'],
'et_builder_get_custom_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'],
'et_builder_get_google_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'],
'et_builder_get_websafe_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'],
'et_pb_is_allowed()' => ['font-upload'],
],
DiviModules::class => [
'ModuleRegistration::get_all_core_modules_metadata()' => ['catalog'],
'ModuleRegistration::get_default_attrs()' => ['catalog'],
@@ -143,7 +165,7 @@ final class DiviFeatureAssignmentTest extends TestCase
}
self::assertSame(self::EXPECTED, $actual);
self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all ten adapters, in report order');
self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all twelve adapters, in report order');
}
public function testReadingAdaptersCarryNoWriteFeature(): void
@@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase;
final class DiviFeatureTest extends TestCase
{
public function testTheThirteenFeaturesAndTheirValues(): void
public function testTheSixteenFeaturesAndTheirValues(): void
{
self::assertSame(
[
@@ -21,6 +21,9 @@ final class DiviFeatureTest extends TestCase
'PresetWrite' => 'preset-write',
'ColorWrite' => 'color-write',
'VariableWrite' => 'variable-write',
'ThemeOptionRead' => 'theme-option-read',
'ThemeOptionWrite' => 'theme-option-write',
'FontUpload' => 'font-upload',
'LibraryRead' => 'library-read',
'LibraryWrite' => 'library-write',
'Roles' => 'roles',
@@ -34,7 +37,7 @@ final class DiviFeatureTest extends TestCase
);
}
public function testOnlyTheSixWriteFeaturesWrite(): void
public function testOnlyTheEightWriteFeaturesWrite(): void
{
$writing = array_values(array_map(
static fn (DiviFeature $feature): string => $feature->value,
@@ -42,7 +45,10 @@ final class DiviFeatureTest extends TestCase
));
self::assertSame(
['page-write', 'preset-write', 'color-write', 'variable-write', 'library-write', 'tb-write'],
[
'page-write', 'preset-write', 'color-write', 'variable-write', 'theme-option-write', 'font-upload',
'library-write', 'tb-write',
],
$writing
);
}
@@ -56,6 +62,9 @@ final class DiviFeatureTest extends TestCase
self::assertSame('Theme Builder lesen', DiviFeature::ThemeBuilderRead->label());
self::assertSame('Farben schreiben', DiviFeature::ColorWrite->label());
self::assertSame('Variablen schreiben', DiviFeature::VariableWrite->label());
self::assertSame('Theme-Optionen lesen', DiviFeature::ThemeOptionRead->label());
self::assertSame('Theme-Optionen schreiben', DiviFeature::ThemeOptionWrite->label());
self::assertSame('Schriften hochladen', DiviFeature::FontUpload->label());
foreach ($labels as $label) {
self::assertNotSame('', trim($label));
}
@@ -64,6 +73,7 @@ final class DiviFeatureTest extends TestCase
public function testValuesResolveBackToTheFeature(): void
{
self::assertSame(DiviFeature::ThemeBuilderWrite, DiviFeature::from('tb-write'));
self::assertSame(DiviFeature::ThemeOptionWrite, DiviFeature::from('theme-option-write'));
self::assertNull(DiviFeature::tryFrom('page'));
}
}