fix: TB dry runs hide uneditable live templates, off-switch despite draft (rulings Q20, Q21)

- ThemeBuilderAccess::editableLiveTemplates() shared by tb-list and the tb-assign/tb-activate dry runs;
  uneditable live templates only as a count (hidden_overlaps, hidden_live)
- tb-activate enabled:false works while the Theme Builder has a draft, with a warning
- possible_overlaps skips identical conditions; reattach records the previous enabled state;
  exclude_from must be a list; tb-activate live from the read-back

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 20:55:53 +02:00
co-authored by Claude Opus 5.5
parent 64fc439cb2
commit a31d4340f9
11 changed files with 355 additions and 37 deletions
@@ -24,12 +24,18 @@ defined('ABSPATH') || exit;
* in the change log. Refused for the default template, a template the live Theme Builder does not list (reattach * in the change log. Refused for the default template, a template the live Theme Builder does not list (reattach
* first) and, when enabling, one without conditions (it would apply nowhere) or with a layout that is no longer * first) and, when enabling, one without conditions (it would apply nowhere) or with a layout that is no longer
* published (B10). Switching off is the fast way back and fail-safe: it also reaches a live template that is not * published (B10). Switching off is the fast way back and fail-safe: it also reaches a live template that is not
* published, e.g. in the trash (ruling Q16), and never needs the layouts. * published, e.g. in the trash (ruling Q16), never needs the layouts and works despite a Theme Builder draft (with
* DRAFT_WARNING, ruling Q20). Overlaps name only templates the user may edit, the others as a count (ruling Q21).
*/ */
final class TbActivateAbility implements Ability final class TbActivateAbility implements Ability
{ {
public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.'; public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.';
/** Ruling Q20: the off-switch works despite a Theme Builder draft – which may restore its own state. */
public const DRAFT_WARNING = 'Der Divi Theme Builder hat ungespeicherte Änderungen (Entwurf); wird ein dort '
. 'geöffnetes Fenster gespeichert, stellt Divi evtl. seinen eigenen Stand wieder her und die Vorlage ist '
. 'wieder aktiv – danach mit lit/tb-list prüfen oder die Änderungen im Theme Builder verwerfen.';
public function __construct( public function __construct(
private ThemeBuilderAccess $access, private ThemeBuilderAccess $access,
private TemplateConditions $conditions, private TemplateConditions $conditions,
@@ -171,15 +177,26 @@ final class TbActivateAbility implements Ability
$template->status === 'publish' ? '' : sprintf(' (im Papierkorb oder Status „%s“)', $template->status), $template->status === 'publish' ? '' : sprintf(' (im Papierkorb oder Status „%s“)', $template->status),
implode('; ', $labels) implode('; ', $labels)
); );
$live = $enabled ? $this->access->store()->liveTemplates() : []; $overlaps = Template::overlapDetails(
$enabled ? $this->access->editableLiveTemplates() : ['templates' => [], 'hidden' => []],
$template->id,
$template->useOn
);
$message .= Template::hiddenOverlapsNote($overlaps['hidden_overlaps']);
if (! $enabled && $this->draftPending()) {
$message .= ' ' . self::DRAFT_WARNING;
}
return Errors::confirmRequired($message, [ return Errors::confirmRequired($message, [
'use_on' => $labels, 'use_on' => $labels,
'exclude_from' => array_map($this->conditions->label(...), $template->excludeFrom), 'exclude_from' => array_map($this->conditions->label(...), $template->excludeFrom),
'layouts' => $template->layouts, 'layouts' => $template->layouts,
'overlaps' => Template::overlaps($live, $template->id, $template->useOn), ] + $overlaps);
'possible_overlaps' => Template::possibleOverlaps($live, $template->id, $template->useOn), }
]);
private function draftPending(): bool
{
return $this->access->store()->draftId() > 0;
} }
/** /**
@@ -203,6 +220,9 @@ final class TbActivateAbility implements Ability
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).', . 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
$warnings $warnings
); );
if (! $enabled && $this->draftPending()) {
$warnings[] = self::DRAFT_WARNING;
}
$logId = $this->log->record( $logId = $this->log->record(
$this->name(), $this->name(),
DiviThemeBuilder::TEMPLATE, DiviThemeBuilder::TEMPLATE,
@@ -214,7 +234,7 @@ final class TbActivateAbility implements Ability
$template->useOn === [] ? 'keine' : implode(', ', $template->useOn) $template->useOn === [] ? 'keine' : implode(', ', $template->useOn)
) )
); );
$after = $store->template($template->id, true) ?? $template; $after = $store->template($template->id, true);
$summary = $enabled $summary = $enabled
? sprintf( ? sprintf(
@@ -224,7 +244,7 @@ final class TbActivateAbility implements Ability
) )
: sprintf('Vorlage „%s“ deaktiviert – sie gilt nicht mehr für Besucher.', $template->title); : sprintf('Vorlage „%s“ deaktiviert – sie gilt nicht mehr für Besucher.', $template->title);
return $this->result($after, $summary, $logId, $enabled, $warnings); return $this->result($after ?? $template, $summary, $logId, $enabled, $after?->isLive() ?? $enabled, $warnings);
} }
/** @throws WriteFailed Always. */ /** @throws WriteFailed Always. */
@@ -260,13 +280,14 @@ final class TbActivateAbility implements Ability
string $summary, string $summary,
?int $logId, ?int $logId,
?bool $enabled = null, ?bool $enabled = null,
?bool $live = null,
array $warnings = [] array $warnings = []
): array { ): array {
return ToolResult::ok($summary) return ToolResult::ok($summary)
->with('template_id', $template->id) ->with('template_id', $template->id)
->with('template_rev', $template->rev()) ->with('template_rev', $template->rev())
->with('enabled', $enabled ?? $template->enabled) ->with('enabled', $enabled ?? $template->enabled)
->with('live', $enabled ?? $template->isLive()) ->with('live', $live ?? $template->isLive())
->warnings($warnings) ->warnings($warnings)
->logId($logId) ->logId($logId)
->urls(null, (string) admin_url('admin.php?page=et_theme_builder')) ->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
@@ -118,9 +118,14 @@ final class TbAssignAbility implements Ability
$useOn = is_array($input['use_on'] ?? null) $useOn = is_array($input['use_on'] ?? null)
? $this->conditions->check($input['use_on'], 'use_on') ? $this->conditions->check($input['use_on'], 'use_on')
: ['ids' => [], 'errors' => ['use_on' => 'Liste von Bedingungs-IDs erwartet (leere Liste = keine)']]; : ['ids' => [], 'errors' => ['use_on' => 'Liste von Bedingungs-IDs erwartet (leere Liste = keine)']];
$excludeFrom = array_key_exists('exclude_from', $input) $excludeFrom = match (true) {
? $this->conditions->check($input['exclude_from'] ?? [], 'exclude_from') ! array_key_exists('exclude_from', $input) => ['ids' => $template->excludeFrom, 'errors' => []],
: ['ids' => $template->excludeFrom, 'errors' => []]; is_array($input['exclude_from']) => $this->conditions->check($input['exclude_from'], 'exclude_from'),
default => [
'ids' => [],
'errors' => ['exclude_from' => 'Liste von Bedingungs-IDs erwartet ([] entfernt die Ausnahmen)'],
],
};
$errors = array_merge($useOn['errors'], $excludeFrom['errors']); $errors = array_merge($useOn['errors'], $excludeFrom['errors']);
if ($errors !== []) { if ($errors !== []) {
return Errors::invalidInput('Bedingungen sind ungültig; nichts wurde gespeichert.', $errors); return Errors::invalidInput('Bedingungen sind ungültig; nichts wurde gespeichert.', $errors);
@@ -181,20 +186,19 @@ final class TbAssignAbility implements Ability
*/ */
private function dryRun(Template $template, array $useOn, array $excludeFrom): WP_Error private function dryRun(Template $template, array $useOn, array $excludeFrom): WP_Error
{ {
$live = $this->access->store()->liveTemplates(); $overlaps = Template::overlapDetails($this->access->editableLiveTemplates(), $template->id, $useOn);
return Errors::confirmRequired( return Errors::confirmRequired(
sprintf( sprintf(
'Vorlage „%s“ ist aktiv; die neuen Bedingungen (use_on) und Ausnahmen (exclude_from) wirken sofort auf ' 'Vorlage „%s“ ist aktiv; die neuen Bedingungen (use_on) und Ausnahmen (exclude_from) wirken sofort auf '
. 'der Website – vorher/nachher unter before/after. Nichts wurde gespeichert (Probelauf).', . 'der Website – vorher/nachher unter before/after.%s Nichts wurde gespeichert (Probelauf).',
$template->title $template->title,
Template::hiddenOverlapsNote($overlaps['hidden_overlaps'])
), ),
[ [
'before' => $this->labels($template->useOn, $template->excludeFrom), 'before' => $this->labels($template->useOn, $template->excludeFrom),
'after' => $this->labels($useOn, $excludeFrom), 'after' => $this->labels($useOn, $excludeFrom),
'overlaps' => Template::overlaps($live, $template->id, $useOn), ] + $overlaps
'possible_overlaps' => Template::possibleOverlaps($live, $template->id, $useOn),
]
); );
} }
@@ -256,7 +260,12 @@ final class TbAssignAbility implements Ability
sprintf( sprintf(
'Theme Builder: Bedingungen von „%s“ gesetzt%s (vorher: %s; Ausnahmen vorher: %s)', 'Theme Builder: Bedingungen von „%s“ gesetzt%s (vorher: %s; Ausnahmen vorher: %s)',
$template->title, $template->title,
$reattach ? ', wieder zugeordnet (deaktiviert)' : '', $reattach
? sprintf(
', wieder zugeordnet (deaktiviert, vorher %s)',
$template->enabled ? 'aktiviert' : 'deaktiviert'
)
: '',
self::listed($template->useOn), self::listed($template->useOn),
self::listed($template->excludeFrom) self::listed($template->excludeFrom)
) )
@@ -276,7 +285,8 @@ final class TbAssignAbility implements Ability
->with('template_id', $template->id) ->with('template_id', $template->id)
->with('template_rev', $after?->rev() ?? '') ->with('template_rev', $after?->rev() ?? '')
->with('live', $live) ->with('live', $live)
->with('previous', ['use_on' => $template->useOn, 'exclude_from' => $template->excludeFrom]) ->with('previous', ['use_on' => $template->useOn, 'exclude_from' => $template->excludeFrom]
+ ($reattach ? ['enabled' => $template->enabled] : []))
->warnings($warnings) ->warnings($warnings)
->logId($logId) ->logId($logId)
->urls(null, (string) admin_url('admin.php?page=et_theme_builder')) ->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
@@ -19,8 +19,8 @@ defined('ABSPATH') || exit;
* Divi's unused mark – Divi trashes marked posts 7 days later, F19, stage3b finding 10) so they can be * Divi's unused mark – Divi trashes marked posts 7 days later, F19, stage3b finding 10) so they can be
* reattached; optionally Divi's condition catalog. Needs ThemeBuilderAccess::canUse(). Ruling Q8/B11: a * reattached; optionally Divi's condition catalog. Needs ThemeBuilderAccess::canUse(). Ruling Q8/B11: a
* template the user may not edit does not appear at all (not listed, not in shared_with, asked for by ID it * template the user may not edit does not appear at all (not listed, not in shared_with, asked for by ID it
* reads like a missing one – P12); layout titles only for layouts the user may edit; condition labels name * reads like a missing one – P12) – active ones only as a count (hidden_live, ruling Q21); layout titles only for
* objects by the stored ID only, never by title. * layouts the user may edit; condition labels name objects by the stored ID only, never by title.
*/ */
final class TbListAbility implements Ability final class TbListAbility implements Ability
{ {
@@ -71,12 +71,16 @@ final class TbListAbility implements Ability
'properties' => [ 'properties' => [
'theme_builder_id' => ['type' => 'integer'], 'theme_builder_id' => ['type' => 'integer'],
'draft_pending' => ['type' => 'boolean'], 'draft_pending' => ['type' => 'boolean'],
'hidden_live' => [
'type' => 'integer',
'description' => 'Aktive Vorlagen, die der Nutzer nicht bearbeiten darf (nur die Anzahl).',
],
'templates' => $objects, 'templates' => $objects,
'detached' => $objects, 'detached' => $objects,
'conditions' => $objects, 'conditions' => $objects,
'notes' => ['type' => 'array', 'items' => ['type' => 'string']], 'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
], ],
'required' => ['theme_builder_id', 'draft_pending', 'templates', 'detached', 'notes'], 'required' => ['theme_builder_id', 'draft_pending', 'hidden_live', 'templates', 'detached', 'notes'],
]; ];
} }
@@ -103,12 +107,13 @@ final class TbListAbility implements Ability
} }
$store = $this->access->store(); $store = $this->access->store();
$editable = fn (Template $template): bool => $this->access->canEditTemplate($template->id); $split = $this->access->editableLiveTemplates();
$live = array_values(array_filter($store->liveTemplates(), $editable)); $live = $split['templates'];
$hiddenLive = count(array_filter($split['hidden'], static fn (Template $t): bool => $t->isLive()));
$detached = []; $detached = [];
foreach ($store->detachedIds(self::DETACHED_LIMIT) as $id) { foreach ($store->detachedIds(self::DETACHED_LIMIT) as $id) {
$template = $store->template($id, false); $template = $store->template($id, false);
if ($template !== null && $editable($template)) { if ($template !== null && $this->access->canEditTemplate($template->id)) {
$detached[] = $template; $detached[] = $template;
} }
} }
@@ -134,7 +139,15 @@ final class TbListAbility implements Ability
$notes = []; $notes = [];
if ($draft) { if ($draft) {
$notes[] = 'Der Theme Builder hat ungespeicherte Änderungen (Entwurf); bis sie dort gespeichert oder ' $notes[] = 'Der Theme Builder hat ungespeicherte Änderungen (Entwurf); bis sie dort gespeichert oder '
. 'verworfen sind, ändert LIT MCP keine Vorlagen.'; . 'verworfen sind, ändert LIT MCP keine Vorlagen – nur ausblenden (lit/tb-activate mit enabled: '
. 'false) geht.';
}
if ($hiddenLive > 0) {
$notes[] = sprintf(
'Weitere aktive Vorlagen ohne Berechtigung: %d (nicht aufgeführt; sie können sich mit den '
. 'aufgeführten überschneiden).',
$hiddenLive
);
} }
if ($detached !== []) { if ($detached !== []) {
$notes[] = 'detached: Diese Vorlagen hat ein späteres Speichern im Theme Builder entfernt – oder jemand ' $notes[] = 'detached: Diese Vorlagen hat ein späteres Speichern im Theme Builder entfernt – oder jemand '
@@ -146,6 +159,7 @@ final class TbListAbility implements Ability
$result = [ $result = [
'theme_builder_id' => $store->liveId(), 'theme_builder_id' => $store->liveId(),
'draft_pending' => $draft, 'draft_pending' => $draft,
'hidden_live' => $hiddenLive,
'templates' => array_map(fn (Template $t): array => $this->describe($t, $shared), $live), 'templates' => array_map(fn (Template $t): array => $this->describe($t, $shared), $live),
'detached' => array_map(fn (Template $t): array => $this->describe($t, $shared), $detached), 'detached' => array_map(fn (Template $t): array => $this->describe($t, $shared), $detached),
'notes' => $notes, 'notes' => $notes,
@@ -164,8 +164,11 @@ final class Template
} }
$conditions = []; $conditions = [];
foreach ($other->useOn as $theirs) { foreach ($other->useOn as $theirs) {
if (in_array($theirs, $useOn, true)) {
continue; // identical: overlaps()
}
foreach ($useOn as $ours) { foreach ($useOn as $ours) {
if ($theirs !== $ours && self::mayOverlap($theirs, $ours)) { if (self::mayOverlap($theirs, $ours)) {
$conditions[] = $theirs; $conditions[] = $theirs;
break; break;
} }
@@ -179,6 +182,38 @@ final class Template
return $overlaps; return $overlaps;
} }
/**
* The overlap details of a dry run (tb-assign, tb-activate) for template $exceptId with conditions $useOn:
* identical and possible overlaps with the live templates the user may edit, and only the number of
* overlapping live templates the user may not edit (ruling Q21, Q8/B11).
*
* @param array{templates: list<Template>, hidden: list<Template>} $live ThemeBuilderAccess::editableLiveTemplates()
* @param list<string> $useOn
* @return array{overlaps: list<array{id: int, title: string, conditions: list<string>}>,
* possible_overlaps: list<array{id: int, title: string, conditions: list<string>}>, hidden_overlaps: int}
*/
public static function overlapDetails(array $live, int $exceptId, array $useOn): array
{
$hidden = array_merge(
self::overlaps($live['hidden'], $exceptId, $useOn),
self::possibleOverlaps($live['hidden'], $exceptId, $useOn)
);
return [
'overlaps' => self::overlaps($live['templates'], $exceptId, $useOn),
'possible_overlaps' => self::possibleOverlaps($live['templates'], $exceptId, $useOn),
'hidden_overlaps' => count(array_unique(array_column($hidden, 'id'))),
];
}
/** The sentence for hidden_overlaps > 0 (never names them). */
public static function hiddenOverlapsNote(int $count): string
{
return $count > 0
? sprintf(' Weitere aktive Vorlagen ohne Berechtigung, die sich überschneiden können: %d.', $count)
: '';
}
private static function mayOverlap(string $a, string $b): bool private static function mayOverlap(string $a, string $b): bool
{ {
foreach (self::scopesOf($a) as $x) { foreach (self::scopesOf($a) as $x) {
@@ -160,7 +160,7 @@ class ThemeBuilderAccess
* never the default template (E8), the current template_rev. $offSwitch (lit/tb-activate with enabled: false, * never the default template (E8), the current template_rev. $offSwitch (lit/tb-activate with enabled: false,
* ruling Q16): also a template visitors see although it is not published (e.g. in the trash – Divi renders * ruling Q16): also a template visitors see although it is not published (e.g. in the trash – Divi renders
* every attached, enabled template, B8), so it can always be switched off; any other unpublished template * every attached, enabled template, B8), so it can always be switched off; any other unpublished template
* still reads as missing. * still reads as missing. The off-switch also works while the Theme Builder has unsaved changes (ruling Q20).
* *
* @param array<string, mixed> $input template_id, template_rev * @param array<string, mixed> $input template_id, template_rev
*/ */
@@ -177,10 +177,27 @@ class ThemeBuilderAccess
$gate = $this->writableGate(); $gate = $this->writableGate();
return $gate ?? $this->checkedTemplate($id, $input); return $gate ?? $this->checkedTemplate($id, $input, true);
} }
return $this->checkedTemplate((int) $post->ID, $input); return $this->checkedTemplate((int) $post->ID, $input, $offSwitch);
}
/**
* The templates of the live Theme Builder post split by canEditTemplate() (ruling Q21 – one place for
* tb-list and the dry runs of tb-assign/tb-activate): only "templates" may be shown; "hidden" serves counts
* ("weitere aktive Vorlagen ohne Berechtigung"), never its IDs, titles or conditions (Q8/B11, P12).
*
* @return array{templates: list<Template>, hidden: list<Template>}
*/
public function editableLiveTemplates(): array
{
$split = ['templates' => [], 'hidden' => []];
foreach ($this->store->liveTemplates() as $template) {
$split[$this->canEditTemplate($template->id) ? 'templates' : 'hidden'][] = $template;
}
return $split;
} }
/** /**
@@ -297,12 +314,14 @@ class ThemeBuilderAccess
/** /**
* The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked). * The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked).
* The off-switch skips the draft refusal (ruling Q20): hiding a template must always be possible; the caller
* warns that saving an open Theme Builder editor may restore its own state.
* *
* @param array<string, mixed> $input * @param array<string, mixed> $input
*/ */
private function checkedTemplate(int $id, array $input): Template|WP_Error private function checkedTemplate(int $id, array $input, bool $offSwitch): Template|WP_Error
{ {
$refusal = $this->draftRefusal(); $refusal = $offSwitch ? null : $this->draftRefusal();
if ($refusal !== null) { if ($refusal !== null) {
return $refusal; return $refusal;
} }
+1 -1
View File
@@ -126,7 +126,7 @@ final class ThemeBuilderFixtures
$store->allows('draftId')->andReturn($draftId); $store->allows('draftId')->andReturn($draftId);
$store->allows('liveTemplates')->andReturn($templates); $store->allows('liveTemplates')->andReturn($templates);
$store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates)); $store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates));
$store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null); $store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null)->byDefault();
$store->allows('detachedIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $detached)); $store->allows('detachedIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $detached));
$store->allows('flatConditions')->andReturn(self::conditions()); $store->allows('flatConditions')->andReturn(self::conditions());
@@ -50,6 +50,26 @@ final class TbActivateAbilityTest extends TestCase
); );
} }
/** A store with $template on the live list whose read-back follows one setEnabled() call. */
private static function switchable(Template $template): DiviThemeBuilder
{
$current = $template;
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('template')->andReturnUsing(static function () use (&$current): Template {
return $current;
});
$store->shouldReceive('setEnabled')->once()->with($template->id, ! $template->enabled)->andReturnUsing(
static function (int $id, bool $enabled) use (&$current, $template): void {
$current = ThemeBuilderFixtures::template($id, [
'enabled' => $enabled,
'use_on' => $template->useOn,
]);
}
);
return $store;
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private static function input(Template $template, bool $enabled, bool $confirm = false): array private static function input(Template $template, bool $enabled, bool $confirm = false): array
{ {
@@ -91,8 +111,7 @@ final class TbActivateAbilityTest extends TestCase
public function testConfirmedActivationSwitchesOnlyTheFlag(): void public function testConfirmedActivationSwitchesOnlyTheFlag(): void
{ {
$template = ThemeBuilderFixtures::template(1066); $template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$template]); $store = self::switchable($template);
$store->shouldReceive('setEnabled')->once()->with(1066, true);
$store->shouldReceive('clearCaches')->once(); $store->shouldReceive('clearCaches')->once();
$store->shouldReceive('setConditions')->never(); $store->shouldReceive('setConditions')->never();
$store->shouldReceive('attach')->never(); $store->shouldReceive('attach')->never();
@@ -112,8 +131,7 @@ final class TbActivateAbilityTest extends TestCase
public function testDeactivationAlsoNeedsConfirm(): void public function testDeactivationAlsoNeedsConfirm(): void
{ {
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]); $template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$store = ThemeBuilderFixtures::store([$template]); $store = self::switchable($template);
$store->shouldReceive('setEnabled')->once()->with(1066, false);
$store->shouldReceive('clearCaches')->once(); $store->shouldReceive('clearCaches')->once();
$dry = self::ability($store)->execute(self::input($template, false)); $dry = self::ability($store)->execute(self::input($template, false));
@@ -243,4 +261,75 @@ final class TbActivateAbilityTest extends TestCase
} }
$log->assertPartials(['ability' => 'lit/tb-activate', 'object_type' => 'et_template', 'object_id' => '1066']); $log->assertPartials(['ability' => 'lit/tb-activate', 'object_type' => 'et_template', 'object_id' => '1066']);
} }
/** Fix round 1: live is what the read-back shows, not what was asked for. */
public function testLiveComesFromTheReadBack(): void
{
$template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('setEnabled')->once()->with(1066, true);
$store->shouldReceive('clearCaches')->once();
$result = self::ability($store)->execute(self::input($template, true, true));
self::assertTrue($result['enabled']);
self::assertFalse($result['live'], 'the read-back still shows the template disabled');
}
/** Ruling Q21: live templates the user may not edit appear in the dry run only as a number. */
public function testDryRunHidesLiveTemplatesTheUserMayNotEdit(): void
{
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 1071)
);
$template = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:page:all']]);
$mine = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Meine', 'use_on' => ['homepage']]);
$secret = ThemeBuilderFixtures::template(1071, [
'enabled' => true,
'title' => 'Geheim',
'use_on' => ['singular:post_type:page:all'],
]);
$store = ThemeBuilderFixtures::store([$template, $mine, $secret]);
$store->shouldReceive('setEnabled')->never();
$dry = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, true));
self::assertToolError($dry, 'confirm_required');
self::assertSame([], $dry->get_error_data()['overlaps']);
self::assertSame([1070], array_column($dry->get_error_data()['possible_overlaps'], 'id'));
self::assertSame(1, $dry->get_error_data()['hidden_overlaps']);
self::assertStringContainsString('ohne Berechtigung, die sich überschneiden können: 1', self::clientError($dry)['message']);
$all = (string) json_encode([$dry->get_error_data(), self::clientError($dry)], JSON_UNESCAPED_UNICODE);
self::assertStringNotContainsString('1071', $all);
self::assertStringNotContainsString('Geheim', $all);
}
/** Ruling Q20: switching off works while the Theme Builder has unsaved changes – with a warning. */
public function testTheOffSwitchWorksWithADraftAndWarns(): void
{
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$store = ThemeBuilderFixtures::store([$template], 999);
$store->shouldReceive('setEnabled')->once()->with(1066, false);
$store->shouldReceive('clearCaches')->once();
self::assertTrue(self::ability($store)->permission(self::input($template, false)));
$dry = self::ability($store)->execute(self::input($template, false));
self::assertToolError($dry, 'confirm_required');
self::assertStringContainsString('ungespeicherte Änderungen', self::clientError($dry)['message']);
$result = self::ability($store)->execute(self::input($template, false, true));
self::assertTrue($result['ok']);
self::assertStringContainsString('ungespeicherte Änderungen', implode(' ', $result['warnings']));
}
/** Ruling Q20: the draft exemption is for switching off only. */
public function testTheOnSwitchIsRefusedWithADraft(): void
{
$template = ThemeBuilderFixtures::template(1066);
$store = ThemeBuilderFixtures::store([$template], 999);
$store->shouldReceive('setEnabled')->never();
self::assertToolError(self::ability($store)->permission(self::input($template, true)), 'unsupported');
self::assertToolError(self::ability($store)->execute(self::input($template, true, true)), 'unsupported');
}
} }
@@ -176,6 +176,8 @@ final class TbAssignAbilityTest extends TestCase
self::assertTrue($result['ok']); self::assertTrue($result['ok']);
self::assertSame([1066, 1061, 1062, 1063], $unmarked); self::assertSame([1066, 1061, 1062, 1063], $unmarked);
self::assertStringContainsString('wieder zugeordnet', $log->summaries()[0]); self::assertStringContainsString('wieder zugeordnet', $log->summaries()[0]);
self::assertStringContainsString('vorher aktiviert', $log->summaries()[0]);
self::assertTrue($result['previous']['enabled']);
} }
/** Ruling Q8/B10: a layout Divi trashed meanwhile blocks the reattach; nothing is written. */ /** Ruling Q8/B10: a layout Divi trashed meanwhile blocks the reattach; nothing is written. */
@@ -306,4 +308,50 @@ final class TbAssignAbilityTest extends TestCase
self::assertToolError(self::ability($store)->permission(self::input($template, ['template_rev' => 'x'])), 'rev_mismatch'); self::assertToolError(self::ability($store)->permission(self::input($template, ['template_rev' => 'x'])), 'rev_mismatch');
self::assertToolError(self::ability($store)->permission(self::input($template, ['template_id' => 4711])), 'not_found'); self::assertToolError(self::ability($store)->permission(self::input($template, ['template_id' => 4711])), 'not_found');
} }
/** Ruling Q21: live templates the user may not edit appear only as a number in the dry run. */
public function testDryRunHidesLiveTemplatesTheUserMayNotEdit(): void
{
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && in_array($args[0] ?? 0, [1071, 1072], true))
);
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$mine = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Meine', 'use_on' => [self::PERSONS]]);
$secret = ThemeBuilderFixtures::template(1071, ['enabled' => true, 'title' => 'Geheim', 'use_on' => [self::PERSONS]]);
$unrelated = ThemeBuilderFixtures::template(1072, ['enabled' => true, 'title' => 'Geheim 2', 'use_on' => ['404']]);
$store = ThemeBuilderFixtures::store([$template, $mine, $secret, $unrelated]);
$store->shouldReceive('setConditions')->never();
$dry = self::ability($store)->execute(self::input($template));
self::assertToolError($dry, 'confirm_required');
self::assertSame([1070], array_column($dry->get_error_data()['overlaps'], 'id'));
self::assertSame(1, $dry->get_error_data()['hidden_overlaps']);
self::assertStringContainsString('ohne Berechtigung, die sich überschneiden können: 1', self::clientError($dry)['message']);
$all = (string) json_encode([$dry->get_error_data(), self::clientError($dry)], JSON_UNESCAPED_UNICODE);
self::assertStringNotContainsString('1071', $all);
self::assertStringNotContainsString('Geheim', $all);
}
/** Ruling Q20: the draft exemption is for the off-switch only – tb-assign stays refused. */
public function testRefusedWhileTheThemeBuilderHasADraft(): void
{
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$store = ThemeBuilderFixtures::store([$template], 999);
$store->shouldReceive('setConditions')->never();
self::assertToolError(self::ability($store)->execute(self::input($template, ['confirm' => true])), 'unsupported');
}
public function testExcludeFromMustBeAList(): void
{
$template = ThemeBuilderFixtures::template(1066, ['exclude_from' => ['homepage']]);
$store = ThemeBuilderFixtures::store([$template]);
$store->shouldReceive('setConditions')->never();
$refused = self::ability($store)->execute(self::input($template, ['exclude_from' => null]));
self::assertToolError($refused, 'validation_failed');
self::assertArrayHasKey('exclude_from', self::errorFields($refused));
}
} }
@@ -144,6 +144,26 @@ final class TbListAbilityTest extends TestCase
); );
} }
/** Ruling Q21: active templates the user may not edit only as a number, never by ID or title. */
public function testCountsActiveTemplatesTheUserCannotEdit(): void
{
self::denyEditing([1070, 1071]);
$store = ThemeBuilderFixtures::store([
ThemeBuilderFixtures::template(1066),
ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Geheim']),
ThemeBuilderFixtures::template(1071, ['title' => 'Geheim aus']),
]);
$result = self::ability($store)->execute([]);
self::assertSame([1066], array_column($result['templates'], 'id'));
self::assertSame(1, $result['hidden_live']);
self::assertStringContainsString('Weitere aktive Vorlagen ohne Berechtigung: 1', implode(' ', $result['notes']));
$encoded = (string) json_encode($result, JSON_UNESCAPED_UNICODE);
self::assertStringNotContainsString('1070', $encoded);
self::assertStringNotContainsString('Geheim', $encoded);
}
/** Ruling Q8/B11: layout titles only for layouts the user may edit. */ /** Ruling Q8/B11: layout titles only for layouts the user may edit. */
public function testLayoutTitlesOnlyForEditableLayouts(): void public function testLayoutTitlesOnlyForEditableLayouts(): void
{ {
@@ -129,4 +129,39 @@ final class TemplateTest extends TestCase
'a static front page is a page' 'a static front page is a page'
); );
} }
/** Fix round 1: a condition the template shares exactly is in overlaps() only, never twice. */
public function testPossibleOverlapsSkipIdenticalConditions(): void
{
$other = ThemeBuilderFixtures::template(1070, [
'enabled' => true,
'title' => 'Seiten',
'use_on' => ['homepage', 'singular:post_type:page:all'],
]);
$useOn = ['homepage', 'singular:post_type:page:id:1053'];
self::assertSame(['homepage'], Template::overlaps([$other], 1066, $useOn)[0]['conditions']);
self::assertSame(['singular:post_type:page:all'], Template::possibleOverlaps([$other], 1066, $useOn)[0]['conditions']);
}
/** Ruling Q21: overlapping templates the user may not edit only as a number. */
public function testOverlapDetailsCountHiddenTemplatesOnly(): void
{
$mine = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Meine', 'use_on' => ['search']]);
$hiddenExact = ThemeBuilderFixtures::template(1071, ['enabled' => true, 'use_on' => ['search', 'homepage']]);
$hiddenPossible = ThemeBuilderFixtures::template(1072, ['enabled' => true, 'use_on' => ['singular:post_type:page:all']]);
$hiddenOther = ThemeBuilderFixtures::template(1073, ['enabled' => true, 'use_on' => ['404']]);
$details = Template::overlapDetails(
['templates' => [$mine], 'hidden' => [$hiddenExact, $hiddenPossible, $hiddenOther]],
1066,
['search', 'homepage']
);
self::assertSame([['id' => 1070, 'title' => 'Meine', 'conditions' => ['search']]], $details['overlaps']);
self::assertSame([], $details['possible_overlaps']);
self::assertSame(2, $details['hidden_overlaps']);
self::assertStringContainsString('ohne Berechtigung', Template::hiddenOverlapsNote(2));
self::assertSame('', Template::hiddenOverlapsNote(0));
}
} }
@@ -241,4 +241,31 @@ final class ThemeBuilderAccessTest extends TestCase
'rev_mismatch' 'rev_mismatch'
); );
} }
/** Ruling Q21: one split of the live list by canEditTemplate(). */
public function testEditableLiveTemplatesSplitsByTheRightToEdit(): void
{
LibraryFixtures::given([]);
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 1070)
);
$mine = ThemeBuilderFixtures::template(1066);
$other = ThemeBuilderFixtures::template(1070);
$split = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$mine, $other]))->editableLiveTemplates();
self::assertSame([$mine], $split['templates']);
self::assertSame([$other], $split['hidden']);
}
/** Ruling Q20: the off-switch works while the Theme Builder has unsaved changes; nothing else does. */
public function testTheOffSwitchIgnoresTheThemeBuilderDraft(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066)]);
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$template], 999));
$input = ['template_id' => 1066, 'template_rev' => $template->rev()];
self::assertSame($template, $access->changeableTemplate($input, true));
self::assertToolError($access->changeableTemplate($input), 'unsupported');
}
} }