post type for get_post_type(); every other ID names no entry. */ private const TYPES = [ self::PAGE => 'page', self::LIBRARY_ENTRY => 'et_pb_layout', self::LAYOUT => 'et_body_layout', ]; /** What a tool needs whatever its input names: tool => capabilities (DiviFeature values). */ private const EXPECTED = [ // Pages (divi) 'lit/divi-outline' => ['page-read', 'design-read'], 'lit/divi-node-get' => ['page-read', 'design-read'], 'lit/divi-catalog' => ['catalog'], 'lit/divi-design-system' => ['design-read'], 'lit/divi-insert' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'], 'lit/divi-update' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'], 'lit/divi-move' => ['page-read', 'page-write', 'catalog', 'design-read'], 'lit/divi-delete' => ['page-read', 'page-write', 'catalog', 'design-read'], 'lit/divi-duplicate-node' => ['page-read', 'page-write', 'catalog', 'design-read'], 'lit/divi-replace-text' => ['page-read', 'catalog', 'design-read', 'dynamic-sources'], 'lit/divi-node-copy' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'], 'lit/divi-dynamic-sources' => ['dynamic-sources', 'roles'], // Design system (divi) 'lit/divi-preset-get' => ['design-read', 'catalog'], 'lit/divi-presets-set' => ['design-read', 'preset-write', 'catalog'], 'lit/divi-presets-delete' => ['design-read', 'preset-write'], 'lit/divi-design-snapshots' => [], 'lit/divi-design-restore' => [ 'design-read', 'preset-write', 'color-write', 'variable-write', 'theme-option-read', 'theme-option-write', ], 'lit/divi-presets-assign' => [ 'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', 'library-write', 'tb-write', 'roles', ], 'lit/divi-design-detach' => [ 'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', 'library-write', 'tb-write', 'roles', ], 'lit/divi-colors-set' => ['design-read', 'color-write'], 'lit/divi-variables-set' => ['design-read', 'variable-write'], 'lit/divi-theme-options-get' => ['theme-option-read'], 'lit/divi-theme-options-update' => ['theme-option-read', 'theme-option-write'], 'lit/divi-fonts-upload' => ['font-upload'], // Library (divi-library) 'lit/library-list' => ['library-read', 'roles'], 'lit/library-find-similar' => ['page-read', 'catalog', 'roles'], 'lit/library-insert' => [ 'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read', 'roles', ], 'lit/library-make-global' => [ 'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read', 'library-write', 'roles', ], 'lit/library-save' => [ 'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-write', 'roles', ], // Theme Builder (divi-theme-builder) 'lit/tb-list' => ['tb-read', 'roles'], 'lit/tb-create-template' => ['tb-read', 'tb-write', 'roles'], 'lit/tb-assign' => ['tb-read', 'tb-write', 'roles'], 'lit/tb-activate' => ['tb-read', 'tb-write', 'roles'], // WordPress: without the divi key it stays in the core 'lit/content-create' => [], ]; /** The compose set of a new or changed page. */ private const COMPOSE = ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources']; /** * Input-dependent additions: label => [tool, input, capabilities for that input]. * * @return array, 2: list}> */ public static function inputCases(): array { $page = self::PAGE; $library = self::LIBRARY_ENTRY; $layout = self::LAYOUT; $libraryWrite = ['roles', 'library-read', 'library-write']; $layoutWrite = ['roles', 'tb-read', 'tb-write']; return [ 'outline of a page' => ['lit/divi-outline', ['post_id' => $page], ['page-read', 'design-read']], 'outline of a library entry' => [ 'lit/divi-outline', ['post_id' => $library], ['page-read', 'design-read', 'roles'], ], 'outline of a layout' => [ 'lit/divi-outline', ['post_id' => $layout], ['page-read', 'design-read', 'roles'], ], 'node-get of a library entry' => [ 'lit/divi-node-get', ['post_id' => $library, 'id' => 's0'], ['page-read', 'design-read', 'roles'], ], 'outline of an unknown entry' => [ 'lit/divi-outline', ['post_id' => 999], ['page-read', 'design-read'], ], 'outline without post_id' => ['lit/divi-outline', [], ['page-read', 'design-read']], 'insert into a page' => ['lit/divi-insert', ['post_id' => $page], self::COMPOSE], 'insert into a library entry' => [ 'lit/divi-insert', ['post_id' => $library], array_merge(self::COMPOSE, $libraryWrite), ], 'update of a layout' => [ 'lit/divi-update', ['post_id' => $layout], array_merge(self::COMPOSE, $layoutWrite), ], 'move in a layout' => [ 'lit/divi-move', ['post_id' => $layout], array_merge(['page-read', 'page-write', 'catalog', 'design-read'], $layoutWrite), ], 'delete with a numeric string ID' => [ 'lit/divi-delete', ['post_id' => (string) $library], array_merge(['page-read', 'page-write', 'catalog', 'design-read'], $libraryWrite), ], 'node-copy of a page into a page' => [ 'lit/divi-node-copy', ['post_id' => $page, 'source_post_id' => $page], self::COMPOSE, ], 'node-copy out of a library entry' => [ 'lit/divi-node-copy', ['post_id' => $page, 'source_post_id' => $library], array_merge(self::COMPOSE, ['roles']), ], 'node-copy into a library entry' => [ 'lit/divi-node-copy', ['post_id' => $library, 'source_post_id' => $page], array_merge(self::COMPOSE, $libraryWrite), ], 'replace-text dry run by default' => [ 'lit/divi-replace-text', ['search' => 'a', 'replace' => 'b'], ['page-read', 'catalog', 'design-read', 'dynamic-sources'], ], 'replace-text dry run explicit' => [ 'lit/divi-replace-text', ['dry_run' => true, 'targets' => [['post_id' => $library]]], ['page-read', 'catalog', 'design-read', 'dynamic-sources'], ], 'replace-text on pages' => [ 'lit/divi-replace-text', ['dry_run' => false, 'targets' => [['post_id' => $page, 'tree_rev' => 'x']]], array_merge(['page-read', 'catalog', 'design-read', 'dynamic-sources'], ['page-write']), ], 'replace-text on a library entry' => [ 'lit/divi-replace-text', ['dry_run' => false, 'targets' => [['post_id' => $page], ['post_id' => $library], 'bad']], array_merge(['page-read', 'catalog', 'design-read', 'dynamic-sources', 'page-write'], $libraryWrite), ], 'presets-set without from_node' => [ 'lit/divi-presets-set', ['items' => [['module' => 'button', 'name' => 'x']]], ['design-read', 'preset-write', 'catalog'], ], 'presets-set from a page node' => [ 'lit/divi-presets-set', ['items' => [['from_node' => ['post_id' => $page, 'id' => 's0']]]], ['design-read', 'preset-write', 'catalog', 'page-read'], ], 'presets-set from a library node' => [ 'lit/divi-presets-set', ['items' => [1, ['from_node' => ['post_id' => $library, 'id' => 's0']]]], ['design-read', 'preset-write', 'catalog', 'page-read', 'roles'], ], 'design-detach of a variable' => [ 'lit/divi-design-detach', ['variable' => 'Eckenradius'], [ 'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', 'library-write', 'tb-write', 'roles', 'variable-write', ], ], 'design-restore of presets' => [ 'lit/divi-design-restore', ['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'], ['design-read', 'preset-write'], ], 'design-restore of colors' => [ 'lit/divi-design-restore', ['snapshot_id' => 'colors-20261016T100000Z-aaaaaa'], ['design-read', 'color-write'], ], 'design-restore of variables' => [ 'lit/divi-design-restore', ['snapshot_id' => 'variables-20261016T100000Z-aaaaaa'], ['design-read', 'variable-write'], ], 'design-restore of theme_options' => [ 'lit/divi-design-restore', ['snapshot_id' => 'theme_options-20261016T100000Z-aaaaaa'], ['theme-option-read', 'theme-option-write'], ], 'library-list' => ['lit/library-list', [], ['library-read', 'roles']], 'library-list of one entry' => [ 'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'], ], 'library-insert into a layout' => [ 'lit/library-insert', ['post_id' => $layout, 'library_id' => $library], array_merge( ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read'], $layoutWrite ), ], 'tb-create-template without nodes' => [ 'lit/tb-create-template', ['title' => 'T', 'header' => ['source' => 'new']], ['tb-read', 'tb-write', 'roles'], ], 'tb-create-template with nodes' => [ 'lit/tb-create-template', ['title' => 'T', 'body' => ['source' => 'new', 'nodes' => [[]]]], array_merge(['tb-read', 'tb-write', 'roles'], self::COMPOSE), ], 'tb-create-template with empty nodes' => [ 'lit/tb-create-template', ['title' => 'T', 'footer' => ['source' => 'new', 'nodes' => []]], ['tb-read', 'tb-write', 'roles'], ], 'content-create without divi' => ['lit/content-create', ['post_type' => 'page'], []], 'content-create with divi' => [ 'lit/content-create', ['post_type' => 'page', 'divi' => [[]]], self::COMPOSE, ], ]; } protected function setUp(): void { parent::setUp(); Functions\when('__')->returnArg(); Functions\when('get_post_type')->alias(static fn (int $id): string|false => self::TYPES[$id] ?? false); Functions\when('current_user_can')->justReturn(true); } /** @return array name => ability of the three Divi groups and lit/content-create */ private function abilities(): array { $divi = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); $composer = Mockery::mock(ContentComposer::class); $composer->allows('available')->andReturn(true); $composer->allows('requirements')->andReturn(self::COMPOSE); $wordPress = new WordPressIntegration( Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden(), $composer ); $found = []; foreach ([ ...$divi->abilities(), ...$divi->library()->abilities(), ...$divi->themeBuilder()->abilities(), ] as $ability) { $found[$ability->name()] = $ability; } foreach ($wordPress->abilities() as $ability) { if ($ability->name() === 'lit/content-create') { $found[$ability->name()] = $ability; } } return $found; } /** * @param list $features * @return list The values, sorted. */ private static function values(array $features): array { $values = array_map(static fn (DiviFeature $feature): string => $feature->value, $features); sort($values); return $values; } /** @param list $values */ private static function sorted(array $values): array { sort($values); return array_values(array_unique($values)); } public function testEveryToolOfTheDiviGroupsDeclaresItsFeatures(): void { $abilities = $this->abilities(); foreach ($abilities as $name => $ability) { if ($name === 'lit/content-create') { self::assertInstanceOf(DeclaresRequirements::class, $ability, $name); self::assertNotInstanceOf(RequiresDiviFeatures::class, $ability, 'the core imports no Divi class'); continue; } self::assertInstanceOf(RequiresDiviFeatures::class, $ability, $name); } } public function testEveryRowOfTheTableHasAnAbilityAndTheOtherWayRound(): void { $abilities = array_keys($this->abilities()); self::assertSame([], array_values(array_diff(array_keys(self::EXPECTED), $abilities)), 'row without ability'); self::assertSame([], array_values(array_diff($abilities, array_keys(self::EXPECTED))), 'ability without row'); } public function testEveryToolDeclaresExactlyTheTableForAnInputThatNamesNothing(): void { foreach ($this->abilities() as $name => $ability) { self::assertSame( self::sorted(self::EXPECTED[$name] ?? ['']), self::values(FeatureSets::declaredBy($ability, [])), $name ); } } /** * @param array $input * @param list $expected * @dataProvider inputCases */ public function testInputDependentFeatures(string $tool, array $input, array $expected): void { $ability = $this->abilities()[$tool]; self::assertSame(self::sorted($expected), self::values(FeatureSets::declaredBy($ability, $input))); } public function testNoToolDeclaresAFeatureTwiceAndEveryIdIsAFeature(): void { foreach ($this->abilities() as $name => $ability) { $declared = $ability instanceof RequiresDiviFeatures ? array_map(static fn (DiviFeature $f): string => $f->value, $ability->diviFeatures([])) : []; self::assertSame($declared, array_values(array_unique($declared)), $name); } foreach (self::EXPECTED as $name => $ids) { foreach ($ids as $id) { self::assertNotNull(DiviFeature::tryFrom($id), $name . ': ' . $id); } } } public function testEveryWritingToolNeedsTheWriteFeatureOfEverythingItSaves(): void { $writes = [ 'lit/divi-insert' => 'page-write', 'lit/divi-update' => 'page-write', 'lit/divi-move' => 'page-write', 'lit/divi-delete' => 'page-write', 'lit/divi-duplicate-node' => 'page-write', 'lit/divi-node-copy' => 'page-write', 'lit/library-insert' => 'page-write', 'lit/library-make-global' => 'library-write', 'lit/library-save' => 'library-write', 'lit/divi-presets-set' => 'preset-write', 'lit/divi-presets-delete' => 'preset-write', 'lit/divi-colors-set' => 'color-write', 'lit/divi-variables-set' => 'variable-write', 'lit/divi-theme-options-update' => 'theme-option-write', 'lit/divi-fonts-upload' => 'font-upload', 'lit/divi-design-restore' => 'preset-write', 'lit/divi-design-detach' => 'preset-write', 'lit/divi-presets-assign' => 'page-write', 'lit/tb-assign' => 'tb-write', 'lit/tb-activate' => 'tb-write', 'lit/tb-create-template' => 'tb-write', ]; foreach ($writes as $name => $feature) { self::assertContains($feature, self::EXPECTED[$name], $name); } // Read-only tools never need a write capability. foreach (['lit/divi-outline', 'lit/divi-node-get', 'lit/divi-catalog', 'lit/divi-design-system', 'lit/divi-dynamic-sources', 'lit/divi-preset-get', 'lit/library-list', 'lit/library-find-similar', 'lit/tb-list', 'lit/divi-design-snapshots'] as $name) { foreach (self::EXPECTED[$name] as $id) { self::assertFalse(DiviFeature::from($id)->isWrite(), $name . ': ' . $id); } } } }