What the adapter stores (ThemeOptionsAreaTest::adapter()). */ private array $stored = []; /** @var list, 1: list}> */ private array $writes = []; protected function setUp(): void { parent::setUp(); // DesignLock (final review I-5): the lock is always free here. LockTable::install(); Functions\when('current_user_can')->justReturn(true); $this->stored = ThemeOptionsAreaTest::stored(); $this->writes = []; } /** @param (Closure(string, mixed): mixed)|null $sanitize Divi's sanitizer (default: keeps the value). */ private function ability( ?SnapshotStore $snapshots = null, ?Closure $sanitize = null, bool $customize = true, ?ThemeOptionsAdapter $options = null ): DiviThemeOptionsUpdateAbility { return new DiviThemeOptionsUpdateAbility( $options ?? ThemeOptionsAreaTest::adapter($this->stored, $this->writes, $sanitize, $customize), FontCheckTest::catalog(), new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)) ); } /** A SnapshotStore that expects one snapshot of the stored options and the log ID 3 attached to it. */ private static function snapshots(): SnapshotStore { $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save') ->with( 'theme_options', ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(), 'lit/divi-theme-options-update' ) ->andReturn('theme_options-1'); $snapshots->expects('attachLog')->with('theme_options', 'theme_options-1', 3); return $snapshots; } /** * Dry run, then confirm with its design_rev. * * @param array $input * @return array{0: array, 1: mixed} */ private function confirmed(DiviThemeOptionsUpdateAbility $ability, array $input): array { $dry = self::assertToolError($ability->execute($input), 'confirm_required'); return [$dry, $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']])]; } public function testSchemaAndMeta(): void { $ability = $this->ability(); $properties = $ability->inputSchema()['properties']; self::assertSame('lit/divi-theme-options-update', $ability->name()); self::assertFalse($ability->isDirect()); self::assertSame(Annotations::destructive(false), $ability->annotations()); self::assertSame([DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $ability->diviFeatures([])); self::assertSame(ThemeOptionSpec::KEYS, array_keys($properties['set']['properties'])); self::assertFalse($properties['set']['additionalProperties']); self::assertSame(10, $properties['set']['properties']['body_font_size']['minimum']); self::assertSame(32, $properties['set']['properties']['body_font_size']['maximum']); self::assertContains('null', $properties['set']['properties']['heading_font']['type']); self::assertArrayHasKey('design_rev', $properties); self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); self::assertStringContainsString('confirm: true mit design_rev', $ability->description()); self::assertStringContainsString('lit/divi-colors-set', $ability->description()); } /** Spec §10, W14: edit_theme_options and Divi's role right "Theme-Anpassung", before anything is read. */ public function testNeedsEditThemeOptionsAndTheCustomizerRight(): void { $input = ['set' => ['body_font_size' => 18]]; self::assertToolError($this->ability(customize: false)->execute($input), 'forbidden'); self::assertToolError($this->ability(customize: false)->permission($input), 'forbidden'); Functions\when('current_user_can')->justReturn(false); self::assertToolError($this->ability()->execute($input), 'forbidden'); self::assertToolError($this->ability()->permission($input), 'forbidden'); self::assertSame([], $this->writes); } public function testSetMustBeAnObjectWithAtLeastOneKey(): void { foreach ([[], ['set' => []], ['set' => ['body_font']], ['set' => 'body_font']] as $input) { self::assertToolError($this->ability()->execute($input), 'validation_failed'); } } public function testDryRunThenConfirmWritesTheOptions(): void { $input = ['set' => ['heading_font' => 'Roboto', 'body_font_size' => '18']]; [$dry, $result] = $this->confirmed($this->ability(self::snapshots()), $input); self::assertSame( [['heading_font', 'added'], ['body_font_size', 'changed']], array_map(static fn (array $row): array => [$row['key'], $row['action']], $dry['diff']) ); self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->rev(), $dry['design_rev']); self::assertStringContainsString(ThemeOptionDiff::summary($dry['diff']), $dry['message']); self::assertSame([], $dry['affected'], 'theme options have no page reference (W7)'); self::assertSame(ThemeOptionsArea::SITE_WIDE, $dry['warnings'][0]); self::assertStringContainsString('Builder', $dry['warnings'][1]); self::assertSame('theme_options-1', $result['snapshot_id']); self::assertSame(18, $this->stored['body_font_size'], 'stored as the number Divi keeps'); self::assertSame('Roboto', $this->stored['heading_font']); self::assertSame(ThemeOptionBook::fromDivi($this->stored)->rev(), $result['design_rev']); } public function testAFontNameIsTrimmedBeforeTheCatalogCheck(): void { $result = $this->confirmed($this->ability(self::snapshots()), ['set' => ['heading_font' => ' Roboto']])[1]; self::assertSame('theme_options-1', $result['snapshot_id']); self::assertSame('Roboto', $this->stored['heading_font']); } /** Only fonts reach the builder (W7). */ public function testSizesOnlyWarnAboutTheWholeWebsite(): void { $dry = self::assertToolError( $this->ability()->execute(['set' => ['content_width' => 1200]]), 'confirm_required' ); self::assertSame([ThemeOptionsArea::SITE_WIDE], $dry['warnings']); } /** Ruling W3: null resets to Divi's default – the key is deleted, not stored as "none" or false. */ public function testNullResetsToTheDiviDefault(): void { [$dry] = $this->confirmed($this->ability(self::snapshots()), ['set' => ['body_font_height' => null]]); self::assertSame(['reset'], array_column($dry['diff'], 'action')); self::assertContains('body_font_height', $this->writes[0][1]); self::assertArrayNotHasKey('body_font_height', $this->stored); } public function testNoneAndFalseAreRefusedWithAHint(): void { $error = $this->ability()->execute(['set' => ['heading_font' => false, 'body_font' => 'none']]); self::assertToolError($error, 'validation_failed'); $fields = self::errorFields($error); self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields)); self::assertStringContainsString('null', $fields['set.body_font']); } public function testAllProblemsAreCollectedAndUnknownKeysRefused(): void { $error = $this->ability()->execute(['set' => [ 'gutter_width' => 'breit', 'accent_color' => '#ffffff', 'delete' => true, 'body_font_size' => 99, 'body_header_style' => 'bold', ]]); self::assertToolError($error, 'validation_failed'); $fields = self::errorFields($error); self::assertSame( ['set.accent_color', 'set.delete', 'set.body_font_size', 'set.body_header_style', 'set.gutter_width'], array_keys($fields) ); self::assertSame('unbekanntes Feld', $fields['set.accent_color']); self::assertStringContainsString('null', $fields['set.delete']); self::assertSame([], $this->writes); } /** Ruling W5: the exact name of a Divi font; unknown names get suggestions before Divi's sanitizer is asked. */ public function testUnknownFontsGetSuggestions(): void { $asked = []; $sanitize = static function (string $key, mixed $value) use (&$asked): mixed { $asked[] = $key; return $value; }; $error = $this->ability(sanitize: $sanitize)->execute(['set' => [ 'heading_font' => 'robotto', 'body_font' => 'Xyzzy Grotesk', 'primary_nav_font' => 'Lato', ]]); self::assertToolError($error, 'validation_failed'); $fields = self::errorFields($error); self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields)); self::assertStringContainsString('Roboto', $fields['set.heading_font']); self::assertStringContainsString('lit/divi-fonts-upload', $fields['set.body_font']); self::assertSame(['primary_nav_font'], $asked, 'only the known font goes to Divi'); } /** Divi's sanitizer answers false or changes the value – Divi would store false or round (Global Constraints). */ public function testValuesDiviRejectsOrChangesAreErrors(): void { $sanitize = static fn (string $key, mixed $value): mixed => match ($key) { 'body_header_style' => false, 'content_width' => 1000, default => $value, }; $error = $this->ability(sanitize: $sanitize)->execute(['set' => [ 'body_header_style' => 'italic', 'content_width' => 1200, 'row_padding' => 3, ]]); self::assertToolError($error, 'validation_failed'); self::assertSame(['set.body_header_style', 'set.content_width'], array_keys(self::errorFields($error))); } /** Ruling W4: the value is stored with the type Divi's sanitizer returns. */ public function testTheSanitizedTypeIsStored(): void { $sanitize = static fn (string $key, mixed $value): mixed => $key === 'body_font_size' ? (string) $value : $value; $this->confirmed($this->ability(self::snapshots(), $sanitize), ['set' => ['body_font_size' => 20]]); self::assertSame('20', $this->stored['body_font_size']); } /** Ruling W3: a tablet/phone size equal to the size it inherits stays unset and keeps following it. */ public function testATabletSizeEqualToTheInheritedSizeIsNoChange(): void { $same = $this->ability()->execute(['set' => ['tablet_body_font_size' => 16]]); self::assertSame([], $same['diff']); self::assertStringStartsWith('Keine Änderung', $same['summary']); $dry = self::assertToolError( $this->ability()->execute(['set' => ['tablet_body_font_size' => 20, 'body_font_size' => 20]]), 'confirm_required' ); self::assertSame(['body_font_size'], array_column($dry['diff'], 'key'), 'inherits the new desktop size'); $own = self::assertToolError( $this->ability()->execute(['set' => ['tablet_body_font_size' => 15]]), 'confirm_required' ); self::assertSame([['tablet_body_font_size', 'added']], array_map( static fn (array $row): array => [$row['key'], $row['action']], $own['diff'] )); self::assertSame([], $this->writes); } /** A stored false (Divi's default font) is "not set": null is no change, a font is "added". */ public function testAStoredFalseCountsAsNotSet(): void { $this->stored = ['heading_font' => false] + $this->stored; self::assertSame([], $this->ability()->execute(['set' => ['heading_font' => null]])['diff']); $dry = self::assertToolError( $this->ability()->execute(['set' => ['heading_font' => 'Roboto']]), 'confirm_required' ); self::assertSame([['heading_font', 'added']], array_map( static fn (array $row): array => [$row['key'], $row['action']], $dry['diff'] )); } public function testUnchangedValuesAreNoChange(): void { $result = $this->ability()->execute(['set' => ['body_font_size' => 16, 'gutter_width' => null]]); self::assertSame([], $result['diff']); self::assertSame([], $this->writes); } public function testConfirmNeedsTheDesignRevOfTheDryRun(): void { $input = ['set' => ['body_font_size' => 18], 'confirm' => true]; self::assertToolError($this->ability()->execute($input), 'validation_failed'); self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch'); self::assertSame([], $this->writes); } public function testAFailedWriteIsRolledBackToTheSnapshot(): void { $calls = []; $options = Mockery::mock(ThemeOptionsAdapter::class); $options->allows('canCustomize')->andReturn(true); $options->allows('read')->andReturn(ThemeOptionsAreaTest::stored()); $options->allows('sanitize')->andReturnUsing(static fn (string $key, mixed $value): mixed => $value); $options->allows('write')->andReturnUsing(static function (array $set, array $unset) use (&$calls): array { $calls[] = $set; if (count($calls) === 1) { throw new RuntimeException('Zurücklesen stimmt nicht'); } return $set; }); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save')->andReturn('theme_options-1'); try { $this->confirmed($this->ability($snapshots, options: $options), ['set' => ['body_font_size' => 18]]); self::fail('expected WriteFailed'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertSame('theme_options-1', $e->details['snapshot_id']); } self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(), $calls[1]); } }