>> What VariableAreaTest::adapter() stores. */ private array $stored = []; /** @var list, 1: array}> */ private array $saves = []; protected function setUp(): void { parent::setUp(); // DesignLock (final review I-5): the lock is always free here. Functions\when('add_option')->justReturn(true); Functions\when('delete_option')->justReturn(true); Functions\when('current_user_can')->justReturn(true); $this->stored = VariableAreaTest::stored(); $this->saves = []; } /** @param array $presets DesignSystemWriter::presets() ([] = PresetBookTest::stored()). */ private function ability( ?GlobalUsages $usages = null, ?SnapshotStore $snapshots = null, array $presets = [], ?GlobalVariablesAdapter $variables = null, bool $manage = true ): DiviVariablesSetAbility { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->allows('presets')->andReturn($presets === [] ? PresetBookTest::stored() : $presets); $counter = 0; return new DiviVariablesSetAbility( $writer, $variables ?? VariableAreaTest::adapter($this->stored, $this->saves, $manage), new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)), $usages ?? self::usages(self::unused()), static function (int $length) use (&$counter): string { return str_pad((string) ++$counter, $length, '0', STR_PAD_LEFT); } ); } private static function usages(UsageReport $report): GlobalUsages { $usages = Mockery::mock(GlobalUsages::class); $usages->allows('reportFor')->andReturn($report); return $usages; } private static function unused(): UsageReport { return new UsageReport(0, [], 0, 0, [], 0, false); } private static function used(): UsageReport { return new UsageReport(1, [['post_id' => 7, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']], 0, 1, [], 0, false); } /** A SnapshotStore that expects one snapshot of the stored variables and the log ID 3 attached to it. */ private static function snapshots(): SnapshotStore { $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save') ->with('variables', VariableBook::fromDivi(VariableAreaTest::stored())->data(), 'lit/divi-variables-set') ->andReturn('variables-1'); $snapshots->expects('attachLog')->with('variables', 'variables-1', 3); return $snapshots; } public function testSchemaAndMeta(): void { $ability = $this->ability(); $properties = $ability->inputSchema()['properties']; self::assertSame('lit/divi-variables-set', $ability->name()); self::assertFalse($ability->isDirect()); self::assertSame(Annotations::destructive(false), $ability->annotations()); self::assertSame([DiviFeature::DesignRead, DiviFeature::VariableWrite], $ability->diviFeatures([])); self::assertSame(50, DiviVariablesSetAbility::MAX_ITEMS); self::assertSame(DiviVariablesSetAbility::MAX_ITEMS, $properties['items']['maxItems']); self::assertArrayNotHasKey('delete', $properties['items']['items']['properties'], 'ruling V1: only archived'); self::assertArrayHasKey('design_rev', $properties); self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); self::assertStringContainsString('confirm: true mit design_rev', $ability->description()); self::assertStringContainsString('lit/divi-design-restore', $ability->description()); self::assertStringContainsString('lit/divi-design-detach', $ability->description()); } public function testNeedsTheVariablesManagerAndEditThemeOptions(): void { $input = ['items' => [['type' => 'numbers', 'label' => 'Abstand', 'value' => '24px']]]; self::assertToolError($this->ability(manage: false)->execute($input), 'forbidden'); self::assertToolError($this->ability(manage: false)->permission($input), 'forbidden'); Functions\when('current_user_can')->justReturn(false); self::assertToolError($this->ability()->execute($input), 'forbidden'); self::assertSame([], $this->saves); } public function testItemsMustBeAListOfOneToFifty(): void { $tooMany = array_fill(0, 51, ['type' => 'numbers', 'label' => 'X', 'value' => '1px']); foreach ([[], ['items' => []], ['items' => ['a' => []]], ['items' => $tooMany]] as $input) { self::assertToolError($this->ability()->execute($input), 'validation_failed'); } } public function testDryRunThenConfirmCreatesAVariable(): void { $usages = Mockery::mock(GlobalUsages::class); $usages->expects('reportFor')->never(); // ruling V8: a new variable has no usages $ability = $this->ability($usages, self::snapshots()); $input = ['items' => [['type' => 'numbers', 'label' => 'Abstand', 'value' => '24px']]]; $dry = self::assertToolError($ability->execute($input), 'confirm_required'); self::assertSame(['added'], array_column($dry['diff'], 'action')); self::assertSame('Abstand', $dry['diff'][0]['name']); self::assertSame(VariableBook::fromDivi(VariableAreaTest::stored())->rev(), $dry['design_rev']); self::assertSame([], $dry['affected']); self::assertSame([], $dry['warnings']); self::assertStringContainsString(VariableDiff::summary($dry['diff']), $dry['message']); self::assertSame([], $this->saves, 'the dry run saves nothing'); $result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); self::assertSame('variables-1', $result['snapshot_id']); $created = array_diff_key($this->stored['numbers'], VariableAreaTest::stored()['numbers']); self::assertCount(1, $created); $id = (string) array_key_first($created); self::assertMatchesRegularExpression('/^gvid-[0-9a-z]{10}$/D', $id); self::assertSame( [$id, 'Abstand', '24px', '2', 'active', 'numbers'], [$created[$id]['id'], $created[$id]['label'], $created[$id]['value'], $created[$id]['order'], $created[$id]['status'], $created[$id]['variableType']] ); self::assertMatchesRegularExpression( '/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/D', $created[$id]['lastUpdated'] ); self::assertSame(VariableAreaTest::stored(), $this->saves[0][1], 'Divi checks against the inventory read'); self::assertSame(VariableBook::fromDivi($this->stored)->rev(), $result['design_rev']); } public function testConfirmNeedsTheDesignRevOfTheDryRun(): void { $input = ['items' => [['id' => 'Eckenradius', 'value' => '12px']], 'confirm' => true]; self::assertToolError($this->ability()->execute($input), 'validation_failed'); self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch'); self::assertSame([], $this->saves); } public function testChangingValueAndLabelListsTheUsages(): void { $usages = Mockery::mock(GlobalUsages::class); $usages->expects('reportFor') ->twice() // dry run and confirm ->with(Mockery::on(static fn (ReferenceUsageQuery $query): bool => $query->matches('') && ! $query->matches(''))) ->andReturn(self::used()); $ability = $this->ability($usages, self::snapshots()); $input = ['items' => [['id' => 'slogan', 'label' => 'Leitsatz', 'value' => 'Holz mit Seele']]]; $dry = self::assertToolError($ability->execute($input), 'confirm_required'); self::assertSame(['changed'], array_column($dry['diff'], 'action')); self::assertSame(7, $dry['affected'][0]['post_id']); $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); $slogan = $this->stored['strings']['gvid-slogan0001']; self::assertSame(['Leitsatz', 'Holz mit Seele', '1', 'active'], [$slogan['label'], $slogan['value'], $slogan['order'], $slogan['status']]); } public function testTheTypeOfAVariableStays(): void { $error = $this->ability()->execute(['items' => [['id' => 'Eckenradius', 'type' => 'strings', 'value' => 'x']]]); self::assertToolError($error, 'validation_failed'); self::assertSame(['items[0].type'], array_keys(self::errorFields($error))); } /** Ruling V6: gradients are passed through, never changed by the tool. */ public function testGradientsAreLeftToTheBuilder(): void { $error = $this->ability()->execute(['items' => [['id' => 'Verlauf', 'status' => 'archived']]]); self::assertToolError($error, 'validation_failed'); self::assertSame(['items[0].id'], array_keys(self::errorFields($error))); } /** Ruling V1: archiving clears the order; activating appends the variable at the end of its type. */ public function testArchivingClearsTheOrderAndActivatingAppendsIt(): void { $ability = $this->ability(null, self::snapshots()); $input = ['items' => [ ['id' => 'Eckenradius', 'status' => 'archived'], ['id' => 'Alter Slogan', 'status' => 'active'], ]]; $dry = self::assertToolError($ability->execute($input), 'confirm_required'); $actions = array_column($dry['diff'], 'action'); sort($actions); self::assertSame(['activated', 'archived'], $actions); $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); $radius = $this->stored['numbers']['gvid-radius0001']; $old = $this->stored['strings']['gvid-alt0000001']; self::assertSame(['archived', ''], [$radius['status'], $radius['order']]); self::assertSame(['active', '2'], [$old['status'], $old['order']]); self::assertSame('8px', $radius['value'], 'archiving keeps the value'); } public function testActiveVariablesNeedAValue(): void { $error = $this->ability()->execute(['items' => [['id' => 'gvid-leer000001', 'status' => 'active']]]); self::assertToolError($error, 'validation_failed'); self::assertSame(['items[0].value'], array_keys(self::errorFields($error))); } public function testAllProblemsOfAllItemsAreCollected(): void { $error = $this->ability()->execute(['items' => [ ['id' => 'Fehlt', 'value' => '1px'], ['type' => 'colors', 'label' => 'Farbe', 'value' => '#fff'], ['type' => 'numbers', 'label' => 'eckenradius', 'value' => '1px'], ['type' => 'numbers', 'label' => 'Neu', 'value' => '1px;}'], ['type' => 'numbers', 'label' => 'Ohne Wert'], ['id' => 'Slogan', 'status' => 'gelöscht'], 'kein Objekt', ]]); self::assertToolError($error, 'validation_failed'); self::assertSame( ['items[0].id', 'items[1].type', 'items[2].label', 'items[3].value', 'items[4].value', 'items[5].status', 'items[6]'], array_keys(self::errorFields($error)) ); self::assertSame([], $this->saves); } /** Ruling V1: "delete" (or a typo) must not look like a successful no-op. */ public function testUnknownItemKeysAreRefusedAndCollectedWithOtherErrors(): void { $error = $this->ability()->execute(['items' => [ ['id' => 'Slogan', 'delete' => true], ['id' => 'Slogan', 'stauts' => 'archived'], ['id' => 'Fehlt', 'value' => '1px'], ]]); self::assertToolError($error, 'validation_failed'); $fields = self::errorFields($error); self::assertSame(['items[0].delete', 'items[1].stauts', 'items[2].id'], array_keys($fields)); self::assertStringContainsString('archiviert', $fields['items[0].delete']); self::assertSame('unbekanntes Feld', $fields['items[1].stauts']); self::assertSame([], $this->saves); self::assertFalse($this->ability()->inputSchema()['properties']['items']['items']['additionalProperties']); } /** Divi silently drops variables with an empty label or the label "0" (sanitize_global_variables_data). */ public function testEmptyAndZeroLabelsAreRefused(): void { $error = $this->ability()->execute(['items' => [ ['type' => 'numbers', 'label' => '', 'value' => '1px'], ['type' => 'numbers', 'label' => '0', 'value' => '1px'], ['id' => 'Eckenradius', 'label' => ' '], ]]); self::assertToolError($error, 'validation_failed'); self::assertSame(['items[0].label', 'items[1].label', 'items[2].label'], array_keys(self::errorFields($error))); } public function testAFailedWriteIsRolledBack(): void { $calls = []; $variables = Mockery::mock(GlobalVariablesAdapter::class); $variables->allows('canManage')->andReturn(true); $variables->allows('read')->andReturn(VariableAreaTest::stored()); $variables->allows('save')->andReturnUsing(static function (array $data, array $expected) use (&$calls): void { $calls[] = $data; if (count($calls) === 1) { throw new RuntimeException('global_variables_stale'); } }); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save')->andReturn('variables-1'); $ability = $this->ability(null, $snapshots, [], $variables); $input = ['items' => [['id' => 'Eckenradius', 'value' => '12px']]]; $dry = self::assertToolError($ability->execute($input), 'confirm_required'); try { $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); self::fail('expected WriteFailed'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertSame('variables-1', $e->details['snapshot_id']); } self::assertCount(2, $calls); self::assertSame(VariableBook::fromDivi(VariableAreaTest::stored())->data(), $calls[1], 'the snapshot again'); } /** Ruling V1a/V8: archiving a used variable is allowed; presets and posts are warnings, the dry run lists posts. */ public function testArchivingAUsedVariableWarnsAndListsTheUsages(): void { $dry = self::assertToolError( $this->ability(self::usages(self::used()), null, PresetBookTest::storedWithReferences()) ->execute(['items' => [['id' => 'Slogan', 'status' => 'archived']]]), 'confirm_required' ); self::assertSame(7, $dry['affected'][0]['post_id']); self::assertSame([ 'Variable „Slogan“ wird archiviert, aber noch von Presets verwendet: Doppelt.', 'Die unter affected genannten Inhalte verweisen auf geänderte oder archivierte Variablen; archivierte ' . 'Variablen bleiben dort als Verweis stehen. lit/divi-design-detach mit variable ersetzt die Verweise ' . 'vorher durch den Wert.', ], $dry['warnings']); } public function testChangingAVariableWarnsAboutThePresetsThatUseIt(): void { $dry = self::assertToolError( $this->ability(null, null, PresetBookTest::storedWithReferences()) ->execute(['items' => [['id' => 'Eckenradius', 'value' => '12px']]]), 'confirm_required' ); self::assertSame( ['Die Änderung an Variable „Eckenradius“ wirkt auch auf die Presets Lesetext.'], $dry['warnings'] ); } public function testNewVariablesHaveNoWarnings(): void { $dry = self::assertToolError( $this->ability(null, null, PresetBookTest::storedWithReferences()) ->execute(['items' => [['type' => 'strings', 'label' => 'Gruß', 'value' => 'Hallo']]]), 'confirm_required' ); self::assertSame([], $dry['warnings']); } }