diff --git a/src/Attribute.php b/src/Attribute.php index ddf23bcf..19f07063 100644 --- a/src/Attribute.php +++ b/src/Attribute.php @@ -2,174 +2,18 @@ namespace ipl\Html; -use InvalidArgumentException; +use ipl\Html\Common\BaseAttribute; /** - * HTML Attribute - * - * Every single HTML attribute is (or should be) an instance of this class. - * This guarantees that every attribute is safe and escaped correctly. - * - * Usually attributes are not instantiated directly, but created through an HTML - * element's exposed methods. - * - * @phpstan-type _AttributeScalar string|bool|null - * @phpstan-type AttributeValue _AttributeScalar|array<_AttributeScalar> + * @phpstan-import-type AttributeValue from BaseAttribute */ -class Attribute +class Attribute extends BaseAttribute { - /** @var string */ - protected $name; - - /** @var string The separator used if value is an array */ - protected $separator = ' '; - - /** @var AttributeValue */ - protected $value; - - /** - * Create a new HTML attribute from the given name and value - * - * @param string $name The name of the attribute - * @param AttributeValue $value The value of the attribute - * - * @throws InvalidArgumentException If the name of the attribute contains special characters - */ public function __construct($name, $value = null) { $this->setName($name)->setValue($value); } - /** - * Create a new HTML attribute from the given name and value - * - * @param string $name The name of the attribute - * @param AttributeValue $value The value of the attribute - * - * @return static - * - * @throws InvalidArgumentException If the name of the attribute contains special characters - */ - public static function create($name, $value) - { - return new static($name, $value); - } - - /** - * Create a new empty HTML attribute from the given name - * - * The value of the attribute will be null after construction. - * - * @param string $name The name of the attribute - * - * @return static - * - * @throws InvalidArgumentException If the name of the attribute contains special characters - */ - public static function createEmpty($name) - { - return new static($name, null); - } - - /** - * Escape the name of an attribute - * - * Makes sure that the name of an attribute really is a string. - * - * @param string $name - * - * @return string - */ - public static function escapeName($name) - { - return (string) $name; - } - - /** - * Escape the value of an attribute - * - * If the value is an array, returns the string representation - * of all array elements joined with the specified glue string. - * - * Values are escaped according to the HTML5 double-quoted attribute value syntax: - * {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 }. - * - * @param string|string[] $value - * @param string $glue Glue string to join elements if value is an array - * - * @return string - */ - public static function escapeValue($value, $glue = ' ') - { - if (is_array($value)) { - $value = implode($glue, $value); - } - - // We force double-quoted attribute value syntax so let's start by escaping double quotes - $value = str_replace('"', '"', $value); - - // In addition, values must not contain ambiguous ampersands - $value = preg_replace_callback( - '/&[0-9A-Z]+;/i', - function ($match) { - $subject = $match[0]; - - if (htmlspecialchars_decode($subject, ENT_COMPAT | ENT_HTML5) === $subject) { - // Ambiguous ampersand - return str_replace('&', '&', $subject); - } - - return $subject; - }, - $value - ); - - return $value; - } - - /** - * Get the name of the attribute - * - * @return string - */ - public function getName() - { - return $this->name; - } - - /** - * Set the name of the attribute - * - * @param string $name - * - * @return $this - * - * @throws InvalidArgumentException If the name contains special characters - */ - protected function setName($name) - { - if (! preg_match('/^[a-z][a-z0-9:-]*$/i', $name)) { - throw new InvalidArgumentException(sprintf( - 'Attribute names with special characters are not yet allowed: %s', - $name - )); - } - - $this->name = $name; - - return $this; - } - - /** - * Get the separator by which multiple values are concatenated with - * - * @return string - */ - public function getSeparator(): string - { - return $this->separator; - } - /** * Set the separator to concatenate multiple values with * @@ -184,16 +28,6 @@ public function setSeparator(string $separator): self return $this; } - /** - * Get the value of the attribute - * - * @return AttributeValue - */ - public function getValue() - { - return $this->value; - } - /** * Set the value of the attribute * @@ -252,80 +86,8 @@ public function removeValue($value) return $this; } - /** - * Test and return true if the attribute is boolean, false otherwise - * - * @return bool - */ - public function isBoolean() - { - return is_bool($this->value); - } - - /** - * Test and return true if the attribute is empty, false otherwise - * - * Null and the empty array will be considered empty. - * - * @return bool - */ - public function isEmpty() - { - return $this->value === null || $this->value === []; - } - - /** - * Render the attribute to HTML - * - * If the value of the attribute is of type boolean, it will be rendered as - * {@link http://www.w3.org/TR/html5/infrastructure.html#boolean-attributes boolean attribute}. - * Note that in this case if the value of the attribute is false, the empty string will be returned. - * - * If the value of the attribute is null or an empty array, - * the empty string will be returned as well. - * - * Escaping of the attribute's value takes place automatically using {@link Attribute::escapeValue()}. - * - * @return string - */ - public function render() - { - if ($this->isEmpty()) { - return ''; - } - - if ($this->isBoolean()) { - if ($this->value) { - return $this->renderName(); - } - - return ''; - } else { - return sprintf( - '%s="%s"', - $this->renderName(), - $this->renderValue() - ); - } - } - - /** - * Render the name of the attribute to HTML - * - * @return string - */ - public function renderName() - { - return static::escapeName($this->name); - } - - /** - * Render the value of the attribute to HTML - * - * @return string - */ - public function renderValue() + public function isImmutable(): false { - return static::escapeValue($this->value, $this->separator); + return false; } } diff --git a/src/Attributes.php b/src/Attributes.php index ed7e6684..cbed2008 100644 --- a/src/Attributes.php +++ b/src/Attributes.php @@ -6,7 +6,11 @@ use ArrayIterator; use InvalidArgumentException; use IteratorAggregate; +use LogicException; +use RuntimeException; +use Throwable; use Traversable; +use UnexpectedValueException; use function ipl\Stdlib\get_php_type; @@ -23,12 +27,15 @@ */ class Attributes implements ArrayAccess, IteratorAggregate { - /** @var Attribute[] */ + /** @var array */ protected $attributes = []; /** @var callable[] */ protected $callbacks = []; + /** @var array */ + private array $newCallbacks = []; + /** @var string */ protected $prefix = ''; @@ -108,7 +115,7 @@ public static function wantAttributes($attributes) /** * Get the collection of attributes as array * - * @return Attribute[] + * @return array */ public function getAttributes() { @@ -366,6 +373,62 @@ public function setPrefix($prefix) return $this; } + /** + * Call the callback for the attribute with the given name + * + * @param string $name + * + * @return ImmutableAttribute + * + * @throws LogicException If the callback's result is not an ImmutableAttribute and none can be created from it + * @throws RuntimeException If the callback throws an exception + */ + public function call(string $name): ImmutableAttribute + { + if (! isset($this->newCallbacks[$name])) { + return ImmutableAttribute::createEmpty($name); + } + + $callback = $this->newCallbacks[$name]; + + try { + $attribute = call_user_func($callback); + } catch (Throwable $e) { + throw new RuntimeException( + 'Error while calling attribute callback: ' . $e->getMessage(), + previous: $e + ); + } + + if ($attribute instanceof ImmutableAttribute) { + return $attribute; + } + + if ($attribute === null || is_scalar($attribute)) { + return ImmutableAttribute::create($name, $attribute); + } + + throw new UnexpectedValueException( + 'An attribute callback must return a scalar, null' . + ' or an ImmutableAttribute, got a ' . get_php_type($attribute) + ); + } + + /** + * Set a callback for the attribute with the given name + * + * @param string $name + * @param callable $callback + * + * @return $this + */ + public function setCallback(string $name, callable $callback): static + { + $this->newCallbacks[$name] = $callback; + + return $this; + } + /** * Register callback for an attribute * @@ -374,7 +437,9 @@ public function setPrefix($prefix) * @param ?callable $setterCallback Callback to call when setting the attribute * * @return $this + * @deprecated Use {@see setCallback()} instead */ + #[\Deprecated('Use setCallback() instead')] public function registerAttributeCallback(string $name, ?callable $callback, ?callable $setterCallback = null): self { if ($callback !== null) { @@ -401,26 +466,25 @@ public function registerAttributeCallback(string $name, ?callable $callback, ?ca * @return string * * @throws InvalidArgumentException If the result of a callback is invalid + * @throws LogicException If both legacy and new attribute callbacks are used at the same time */ public function render() { + $conflicts = array_intersect_key($this->callbacks, $this->newCallbacks); + if (! empty($conflicts)) { + throw new LogicException( + 'Cannot use both legacy and new attribute callbacks at the same time.' + . ' Offending attributes: ' . implode(', ', array_keys($conflicts)) + ); + } + $attributes = $this->attributes; - foreach ($this->callbacks as $name => $callback) { - $attribute = call_user_func($callback); - if ($attribute instanceof Attribute) { - if ($attribute->isEmpty()) { - continue; - } - } elseif ($attribute === null) { + $callbackResults = array_map($this->legacyCall(...), array_keys($this->callbacks)); + $newCallbackResults = array_map($this->call(...), array_keys($this->newCallbacks)); + + foreach (array_merge($newCallbackResults, $callbackResults) as $attribute) { + if ($attribute->isEmpty()) { continue; - } elseif (is_scalar($attribute)) { - $attribute = Attribute::create($name, $attribute); - } else { - throw new InvalidArgumentException(sprintf( - 'A registered attribute callback must return a scalar, null' - . ' or an Attribute, got a %s', - get_php_type($attribute) - )); } $name = $attribute->getName(); @@ -450,6 +514,33 @@ public function render() return $separator . implode($separator, $parts); } + /** + * Call the registered callback for the attribute with the given name + * + * @param string $name + * + * @return Attribute + * + * @throws InvalidArgumentException If the callback's result is not an Attribute and none can be created from it + */ + private function legacyCall(string $name): Attribute + { + $callback = $this->callbacks[$name]; + $attribute = call_user_func($callback); + if ($attribute instanceof Attribute) { + return $attribute; + } + + if ($attribute === null || is_scalar($attribute)) { + return Attribute::create($name, $attribute); + } + + throw new InvalidArgumentException( + 'An attribute callback must return a scalar, null' . + ' or an Attribute, got a ' . get_php_type($attribute) + ); + } + /** * Get whether the attribute with the given name exists * @@ -518,5 +609,10 @@ public function __clone() foreach ($this->attributes as &$attribute) { $attribute = clone $attribute; } + + // Reset callbacks to avoid memory leaks + $this->callbacks = []; + $this->newCallbacks = []; + $this->setterCallbacks = []; } } diff --git a/src/Common/BaseAttribute.php b/src/Common/BaseAttribute.php new file mode 100644 index 00000000..0262eebd --- /dev/null +++ b/src/Common/BaseAttribute.php @@ -0,0 +1,267 @@ + + */ +abstract class BaseAttribute +{ + /** @var string */ + protected $name; + + /** @var string The separator used if value is an array */ + protected $separator = ' '; + + /** @var AttributeValue */ + protected $value; + + /** + * Create a new HTML attribute from the given name and value + * + * @param string $name The name of the attribute + * @param AttributeValue $value The value of the attribute + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public function __construct($name, $value = null) + { + $this->setName($name); + $this->value = $value; + } + + /** + * Create a new HTML attribute from the given name and value + * + * @param string $name The name of the attribute + * @param AttributeValue $value The value of the attribute + * + * @return static + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public static function create($name, $value) + { + return new static($name, $value); + } + + /** + * Create a new empty HTML attribute from the given name + * + * The value of the attribute will be null after construction. + * + * @param string $name The name of the attribute + * + * @return static + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public static function createEmpty($name) + { + return new static($name, null); + } + + /** + * Escape the name of an attribute + * + * Makes sure that the name of an attribute really is a string. + * + * @param string $name + * + * @return string + */ + public static function escapeName($name) + { + return (string) $name; + } + + /** + * Escape the value of an attribute + * + * If the value is an array, returns the string representation + * of all array elements joined with the specified glue string. + * + * Values are escaped according to the HTML5 double-quoted attribute value syntax: + * {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 }. + * + * @param string|string[] $value + * @param string $glue Glue string to join elements if value is an array + * + * @return string + */ + public static function escapeValue($value, $glue = ' ') + { + if (is_array($value)) { + $value = implode($glue, $value); + } + + // We force double-quoted attribute value syntax so let's start by escaping double quotes + $value = str_replace('"', '"', $value); + + // In addition, values must not contain ambiguous ampersands + $value = preg_replace_callback( + '/&[0-9A-Z]+;/i', + function ($match) { + $subject = $match[0]; + + if (htmlspecialchars_decode($subject, ENT_COMPAT | ENT_HTML5) === $subject) { + // Ambiguous ampersand + return str_replace('&', '&', $subject); + } + + return $subject; + }, + $value + ); + + return $value; + } + + /** + * Get the name of the attribute + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set the name of the attribute + * + * @param string $name + * + * @return $this + * + * @throws InvalidArgumentException If the name contains special characters + */ + protected function setName($name) + { + if (! preg_match('/^[a-z][a-z0-9:-]*$/i', $name)) { + throw new InvalidArgumentException(sprintf( + 'Attribute names with special characters are not yet allowed: %s', + $name + )); + } + + $this->name = $name; + + return $this; + } + + /** + * Get the separator by which multiple values are concatenated with + * + * @return string + */ + public function getSeparator(): string + { + return $this->separator; + } + + /** + * Get the value of the attribute + * + * @return AttributeValue + */ + public function getValue() + { + return $this->value; + } + + /** + * Test and return true if the attribute is boolean, false otherwise + * + * @return bool + */ + public function isBoolean() + { + return is_bool($this->value); + } + + /** + * Test and return true if the attribute is empty, false otherwise + * + * Null and the empty array will be considered empty. + * + * @return bool + */ + public function isEmpty() + { + return $this->value === null || $this->value === []; + } + + /** + * Get whether the attribute is immutable + * + * @return bool + */ + abstract public function isImmutable(): bool; + + /** + * Render the attribute to HTML + * + * If the value of the attribute is of type boolean, it will be rendered as + * {@link http://www.w3.org/TR/html5/infrastructure.html#boolean-attributes boolean attribute}. + * Note that in this case if the value of the attribute is false, the empty string will be returned. + * + * If the value of the attribute is null or an empty array, + * the empty string will be returned as well. + * + * Escaping of the attribute's value takes place automatically using {@link Attribute::escapeValue()}. + * + * @return string + */ + public function render() + { + if ($this->isEmpty()) { + return ''; + } + + if ($this->isBoolean()) { + if ($this->value) { + return $this->renderName(); + } + + return ''; + } else { + return sprintf( + '%s="%s"', + $this->renderName(), + $this->renderValue() + ); + } + } + + /** + * Render the name of the attribute to HTML + * + * @return string + */ + public function renderName() + { + return static::escapeName($this->name); + } + + /** + * Render the value of the attribute to HTML + * + * @return string + */ + public function renderValue() + { + return static::escapeValue($this->value, $this->separator); + } +} diff --git a/src/HtmlElement.php b/src/HtmlElement.php index 4f5d1628..7a28ec50 100644 --- a/src/HtmlElement.php +++ b/src/HtmlElement.php @@ -21,7 +21,7 @@ public function __construct($tag, Attributes $attributes = null, ValidHtml ...$c $this->tag = $tag; if ($attributes !== null) { - $this->getAttributes()->merge($attributes); + $this->attributes = $attributes; } $this->setHtmlContent(...$content); diff --git a/src/ImmutableAttribute.php b/src/ImmutableAttribute.php new file mode 100644 index 00000000..8145c2d5 --- /dev/null +++ b/src/ImmutableAttribute.php @@ -0,0 +1,13 @@ +assertFalse($this->simpleAttribute()->isImmutable()); + } + + public function testImmutableAttributeIsImmutable(): void + { + $this->assertTrue($this->immutableAttribute()->isImmutable()); + } + public function testSimpleAttributeCanBeRendered() { $this->assertEquals( @@ -212,13 +223,32 @@ public function testSpecialCharactersInAttributeNamesAreNotYetSupported() Attribute::create('a_a', 'sa'); } + public function testClone(): void + { + $original = $this->simpleAttribute(); + + $clone = clone $original; + $clone->setValue('clone-class'); + + $secondClone = clone $clone; + $secondClone->addValue('clone-clone-class'); + + $this->assertSame('simple', $original->getValue()); + $this->assertSame('clone-class', $clone->getValue()); + $this->assertSame(['clone-class', 'clone-clone-class'], $secondClone->getValue()); + + $clone->removeValue('clone-class'); + + $this->assertSame(['clone-class', 'clone-clone-class'], $secondClone->getValue()); + } + protected function simpleAttribute() { return new Attribute('class', 'simple'); } - protected function complexAttribute() + protected function immutableAttribute() { - return ; + return new ImmutableAttribute('name', 'test'); } } diff --git a/tests/AttributesTest.php b/tests/AttributesTest.php index 8ce23e8b..4864079a 100644 --- a/tests/AttributesTest.php +++ b/tests/AttributesTest.php @@ -2,18 +2,202 @@ namespace ipl\Tests\Html; +use Exception; +use InvalidArgumentException; use ipl\Html\Attribute; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; -use ipl\Html\HtmlString; -use ipl\Html\ValidHtml; +use ipl\Html\ImmutableAttribute; +use LogicException; +use RuntimeException; +use UnexpectedValueException; class AttributesTest extends TestCase { - public function testGetWithNonexistentAttribute() + /** + * @depends testGetWithExistingAttribute + */ + public function testConstructorAcceptsAttributeInstances(): void + { + $attrStub1 = $this->createStub(Attribute::class); + $attrStub1->method('getName')->willReturn('foo'); + $attrStub1->method('getValue')->willReturn('bar'); + + $attrStub2 = $this->createStub(Attribute::class); + $attrStub2->method('getName')->willReturn('baz'); + $attrStub2->method('getValue')->willReturn('qux'); + + $attributes = new Attributes([$attrStub1, $attrStub2]); + + $this->assertSame('foo', $attributes->get('foo')->getName()); + $this->assertSame('bar', $attributes->get('foo')->getValue()); + + $this->assertSame('baz', $attributes->get('baz')->getName()); + $this->assertSame('qux', $attributes->get('baz')->getValue()); + } + + /** + * @depends testGetWithExistingAttribute + */ + public function testConstructorAcceptsAssociativeArrays(): void + { + $attributes = new Attributes([ + 'foo' => 'bar', + 'baz' => 'qux' + ]); + + $this->assertSame('bar', $attributes->get('foo')->getValue()); + $this->assertSame('qux', $attributes->get('baz')->getValue()); + $this->assertSame('foo', $attributes->get('foo')->getName()); + $this->assertSame('baz', $attributes->get('baz')->getName()); + } + + /** + * @depends testGetWithExistingAttribute + * @todo Not sure of the importance of this format. None of the other methods support this. + */ + public function testConstructorAcceptsTwoElementTuples(): void + { + $attributes = new Attributes([ + ['foo', 'bar'], + ['baz', 'qux'] + ]); + + $this->assertSame('bar', $attributes->get('foo')->getValue()); + $this->assertSame('qux', $attributes->get('baz')->getValue()); + $this->assertSame('foo', $attributes->get('foo')->getName()); + $this->assertSame('baz', $attributes->get('baz')->getName()); + } + + public function testWantAttributesAcceptsSelf(): void + { + $attributes = new Attributes(); + $gotAttributes = $attributes->wantAttributes($attributes); + + $this->assertSame($attributes, $gotAttributes); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testConstructorAcceptsTwoElementTuples + * @depends testGetWithExistingAttribute + */ + public function testWantAttributesAcceptsSupportedArrayFormats(): void + { + $associative = Attributes::wantAttributes([ + 'foo' => 'bar', + 'baz' => 'qux' + ]); + + $this->assertSame('foo', $associative->get('foo')->getName()); + $this->assertSame('bar', $associative->get('foo')->getValue()); + $this->assertSame('baz', $associative->get('baz')->getName()); + $this->assertSame('qux', $associative->get('baz')->getValue()); + + $twoElementTuples = Attributes::wantAttributes([ + ['foo', 'bar'], + ['baz', 'qux'] + ]); + + $this->assertSame('foo', $twoElementTuples->get('foo')->getName()); + $this->assertSame('bar', $twoElementTuples->get('foo')->getValue()); + $this->assertSame('baz', $twoElementTuples->get('baz')->getName()); + $this->assertSame('qux', $twoElementTuples->get('baz')->getValue()); + } + + public function testWantAttributesAcceptsNull(): void + { + $this->assertInstanceOf(Attributes::class, Attributes::wantAttributes(null)); + } + + /** + * @depends testWantAttributesAcceptsSelf + * @depends testWantAttributesAcceptsSupportedArrayFormats + */ + public function testWantAttributesThrowsOnInvalidInput(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Attributes instance, array or null expected. Got string instead.'); + + Attributes::wantAttributes('foo'); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + */ + public function testGetAttributes(): void + { + $attributes = new Attributes([ + 'foo' => 'bar', + 'baz' => 'qux' + ]); + + $this->assertCount(2, $attributes->getAttributes()); + $this->assertSame('foo', $attributes->getAttributes()['foo']->getName()); + $this->assertSame('bar', $attributes->getAttributes()['foo']->getValue()); + $this->assertSame('baz', $attributes->getAttributes()['baz']->getName()); + $this->assertSame('qux', $attributes->getAttributes()['baz']->getValue()); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testGetWithNonexistentAttribute + */ + public function testAttributesMerge(): void + { + $attributes = new Attributes(); + $sourceAttributes = Attributes::create([ + 'foo' => 'bar', + 'bar' => 'foo' + ]); + + $attributes->merge($sourceAttributes); + + $this->assertSame('foo', $attributes->get('foo')->getName()); + $this->assertSame('bar', $attributes->get('foo')->getValue()); + $this->assertSame('bar', $attributes->get('bar')->getName()); + $this->assertSame('foo', $attributes->get('bar')->getValue()); + + $moreAttributes = Attributes::create(['foo' => 'rab']); + + $attributes->merge($moreAttributes); + + $this->assertSame('foo', $attributes->get('foo')->getName()); + $this->assertSame(['bar', 'rab'], $attributes->get('foo')->getValue()); + } + + public function testHas(): void { $attributes = new Attributes(); + $this->assertFalse($attributes->has('name')); + + $attributes->set('name', 'value'); + + $this->assertTrue($attributes->has('name')); + } + + /** + * @depends testSetAttribute + */ + public function testGetWithExistingAttribute(): void + { + $attribute = $this->createStub(Attribute::class); + $attribute->method('getName')->willReturn('name'); + $attribute->method('getValue')->willReturn('value'); + + $attributes = new Attributes(); + $attributes->setAttribute($attribute); + + $this->assertSame('value', $attributes->get('name')->getValue()); + } + + public function testGetWithNonexistentAttribute(): void + { + $attributes = new Attributes(); + + $this->assertNull($attributes->get('unknown')->getValue()); + $attributes ->get('name') ->setValue('value'); @@ -24,7 +208,298 @@ public function testGetWithNonexistentAttribute() ); } - public function testForeach() + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testGetWithExistingAttribute + */ + public function testSetAcceptsSelf(): void + { + $attributes = new Attributes(['foo' => 'bar']); + + $newAttributes = new Attributes(); + $newAttributes->set($attributes); + + $this->assertSame('bar', $newAttributes->get('foo')->getValue()); + } + + /** + * @depends testGetWithExistingAttribute + */ + public function testSetAcceptsAttributeInstances(): void + { + $attrStub = $this->createStub(Attribute::class); + $attrStub->method('getName')->willReturn('foo'); + $attrStub->method('getValue')->willReturn('bar'); + + $attributes = new Attributes(); + + $attributes->set($attrStub); + + $this->assertSame('bar', $attributes->get('foo')->getValue()); + } + + /** + * @depends testGetWithExistingAttribute + */ + public function testSetAcceptsAssociativeArrays(): void + { + $attributes = new Attributes(); + + $attributes->set([ + 'foo' => 'bar' + ]); + + $this->assertSame('bar', $attributes->get('foo')->getValue()); + } + + /** + * @depends testGetWithExistingAttribute + */ + public function testSetAcceptsNameAndValue(): void + { + $attributes = new Attributes(); + + $attributes->set('foo', 'bar'); + + $this->assertSame('bar', $attributes->get('foo')->getValue()); + } + + /** + * @depends testGetAttributes + */ + public function testAddAcceptsNull(): void + { + // for whatever reason… + + $attributes = new Attributes(); + + $attributes->add(null); + + $this->assertSame(0, count($attributes->getAttributes())); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testGetWithExistingAttribute + */ + public function testAddAcceptsSelf(): void + { + $attributes = new Attributes(['foo' => 'bar']); + + $attributes->add(new Attributes([ + 'foo' => 'baz', + 'bar' => 'qux' + ])); + + $this->assertSame(['bar', 'baz'], $attributes->get('foo')->getValue()); + $this->assertSame('qux', $attributes->get('bar')->getValue()); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testGetWithExistingAttribute + */ + public function testAddAcceptsAssociativeArrays(): void + { + $attributes = new Attributes(['foo' => 'bar']); + + $attributes->add([ + 'foo' => 'baz', + 'bar' => 'qux' + ]); + + $this->assertSame(['bar', 'baz'], $attributes->get('foo')->getValue()); + $this->assertSame('qux', $attributes->get('bar')->getValue()); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testGetWithExistingAttribute + */ + public function testAddAcceptsAttributeInstances(): void + { + $attrStub1 = $this->createStub(Attribute::class); + $attrStub1->method('getName')->willReturn('foo'); + $attrStub1->method('getValue')->willReturn('baz'); + + $attrStub2 = $this->createStub(Attribute::class); + $attrStub2->method('getName')->willReturn('bar'); + $attrStub2->method('getValue')->willReturn('qux'); + + $attributes = new Attributes(['foo' => 'bar']); + + $attributes->add($attrStub1); + $attributes->add($attrStub2); + + $this->assertSame(['bar', 'baz'], $attributes->get('foo')->getValue()); + $this->assertSame('qux', $attributes->get('bar')->getValue()); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testGetWithExistingAttribute + */ + public function testAddAcceptsNameAndValue(): void + { + $attributes = new Attributes(['foo' => 'bar']); + + $attributes->add('foo', 'baz'); + $attributes->add('bar', 'qux'); + + $this->assertSame(['bar', 'baz'], $attributes->get('foo')->getValue()); + $this->assertSame('qux', $attributes->get('bar')->getValue()); + } + + public function testRemoveReturnsNullIfAttributeDoesNotExist(): void + { + $attributes = new Attributes(); + + $this->assertNull($attributes->remove('foo')); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testHas + */ + public function testRemoveRemovesAllValuesIfNameIsGiven(): void + { + $attributes = new Attributes([ + 'foo' => ['bar', 'baz'] + ]); + + $this->assertSame(['bar', 'baz'], $attributes->remove('foo')->getValue()); + $this->assertFalse($attributes->has('foo')); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testGetWithExistingAttribute + */ + public function testRemoveRemovesValueIfNameAndValueIsGiven(): void + { + $attributes = new Attributes([ + 'foo' => ['bar', 'baz'] + ]); + + // Honestly, at first I wanted to use assertSame('bar', …), but the returned attribute is not a copy of the + // original, it is the original. This means removing an entire attribute is not the same as removing a single + // value. In the first case, remove returns the removed state, and in the second case it returns the attribute's + // remaining state. Also, why is the remaining state not a reset array? The test only succeeds by asserting + // that `'baz'` is *contained*. Comparing with `['baz']` is not possible as the index is different. + $this->assertContains('baz', $attributes->remove('foo', 'bar')->getValue()); + } + + public function testSetAttribute(): void + { + $attrStub = $this->createStub(Attribute::class); + $attrStub->method('getName')->willReturn('foo'); + $attrStub->method('getValue')->willReturn('bar'); + + $attributes = new Attributes(); + $attributes->setAttribute($attrStub); + + $this->assertSame('bar', $attributes->get('foo')->getValue()); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testGetWithExistingAttribute + */ + public function testAddAttribute(): void + { + $attrStub1 = $this->createStub(Attribute::class); + $attrStub1->method('getName')->willReturn('foo'); + $attrStub1->method('getValue')->willReturn('baz'); + + $attrStub2 = $this->createStub(Attribute::class); + $attrStub2->method('getName')->willReturn('bar'); + $attrStub2->method('getValue')->willReturn('qux'); + + $attributes = new Attributes(['foo' => 'bar']); + + $attributes->addAttribute($attrStub1); + $attributes->addAttribute($attrStub2); + + $this->assertSame(['bar', 'baz'], $attributes->get('foo')->getValue()); + $this->assertSame('qux', $attributes->get('bar')->getValue()); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + * @depends testAttributesAreRenderedAsHtmlAttributes + */ + public function testNamesCanHavePrefixes(): void + { + $attributes = new Attributes(['foo' => 'bar']); + $attributes->setPrefix('data-'); + + $this->assertSame(' data-foo="bar"', $attributes->render()); + } + + public function testEmptyAttributesAreRenderedAsEmptyString(): void + { + $this->assertSame('', (new Attributes())->render()); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + */ + public function testAttributesAreRenderedAsHtmlAttributes(): void + { + $attributes = new Attributes([ + 'foo' => 'bar', + 'baz' => 'qux' + ]); + + $this->assertSame(' foo="bar" baz="qux"', $attributes->render()); + } + + /** + * @depends testAttributesAreRenderedAsHtmlAttributes + * @depends testSetAttribute + */ + public function testEmptyAttributesAreIgnoredWhenRendering(): void + { + $emptyAttribute = $this->createMock(Attribute::class); + $emptyAttribute->expects($this->any())->method('getName')->willReturn('foo'); + $emptyAttribute->expects($this->any())->method('isEmpty')->willReturn(true); + $emptyAttribute->expects($this->never())->method('render'); + + $filledAttribute = $this->createMock(Attribute::class); + $filledAttribute->expects($this->any())->method('getName')->willReturn('bar'); + $filledAttribute->expects($this->any())->method('isEmpty')->willReturn(false); + $filledAttribute->expects($this->once())->method('render')->willReturn('bar="baz"'); + + $attributes = new Attributes(); + $attributes->setAttribute($emptyAttribute); + $attributes->setAttribute($filledAttribute); + + $this->assertSame(' bar="baz"', $attributes->render()); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + */ + public function testArrayAccess(): void + { + $attributes = new Attributes(['foo' => 'bar']); + + $this->assertTrue(isset($attributes['foo'])); + $this->assertFalse(isset($attributes['bar'])); + $this->assertSame('bar', $attributes['foo']->getValue()); + $this->assertNull($attributes['bar']->getValue()); + + $attributes['bar'] = 'baz'; + unset($attributes['foo']); + + $this->assertFalse(isset($attributes['foo'])); + $this->assertSame('baz', $attributes['bar']->getValue()); + } + + /** + * @depends testConstructorAcceptsAssociativeArrays + */ + public function testForeach(): void { $attrs = ['foo' => 'bar', 'baz' => 'qux']; @@ -43,119 +518,168 @@ public function testForeach() } } - public function testNativeAttributesAndCallbacks() + /** + * @depends testHas + * @depends testGetWithExistingAttribute + * @depends testConstructorAcceptsAssociativeArrays + * @depends testSetAcceptsNameAndValue + * @depends testRemoveRemovesAllValuesIfNameIsGiven + */ + public function testClone(): void + { + $attributes = new Attributes(['foo' => 'bar']); + + $clone = clone $attributes; + + $attributes->set('bar', 'baz'); + $clone->set('foo', 'qux'); + + $this->assertFalse($clone->has('bar')); + $this->assertSame('bar', $attributes->get('foo')->getValue()); + + $clone->remove('foo'); + + $this->assertTrue($attributes->has('foo')); + } + + /** + * @depends testAttributesAreRenderedAsHtmlAttributes + * @depends testGetWithExistingAttribute + */ + public function testNativeAttributesAndCallbacks(): void { $objectOne = new class extends BaseHtmlElement { protected $defaultAttributes = ['class' => 'foo']; - protected $attr; - public function getAttr() { - return $this->attr; - } - - public function setAttr($val) - { - $this->attr = $val; + return 'bar'; } }; - $objectOne->getAttributes()->registerAttributeCallback( + $objectOne->getAttributes()->setCallback( 'class', - [$objectOne, 'getAttr'], - [$objectOne, 'setAttr'] + $objectOne->getAttr(...) ); - $objectOne->getAttributes()->set('class', 'bar'); - - $this->assertEquals('bar', $objectOne->getAttr()); $this->assertEquals(' class="foo bar"', $objectOne->getAttributes()->render()); - $this->assertEquals('foo', $objectOne->getAttributes()->getAttributes()['class']->getValue()); + $this->assertEquals('foo', $objectOne->getAttributes()->get('class')->getValue()); } - public function testAttributesMerge() + /** + * Merging attributes with callbacks is highly discouraged. Callbacks may hold references to other objects + * and may cause memory leaks. We cannot prevent passing entire attributes instances around, but we must + * not provide a native way to merge them. + * + * @depends testGetWithNonexistentAttribute + * @depends testCallReturnsACallbackResult + */ + public function testAttributesMergeDoesNotMergeCallbacks(): void { - $emptyAttributes = new Attributes(); - $filledAttributes = Attributes::create([ - 'foo' => 'bar', - 'bar' => 'foo' - ]); + $attributes = new Attributes(); + $sourceAttributes = Attributes::create(['bar' => 'foo']) + ->setCallback('foo', fn() => 'bar'); - $emptyAttributes->merge($filledAttributes); + $attributes->merge($sourceAttributes); - $this->assertEquals(' foo="bar" bar="foo"', $emptyAttributes->render()); + $this->assertSame('foo', $attributes->get('bar')->getValue()); + $this->assertNull($attributes->call('foo')->getValue()); + } - $moreAttributes = Attributes::create(['foo' => 'rab']); + public function testCallReturnsACallbackResult(): void + { + $attributes = (new Attributes())->setCallback( + 'callback', + fn() => ImmutableAttribute::create('callback', 'value from callback') + )->setCallback( + 'callback2', + fn() => 'value from callback2' + ); - $moreAttributes->merge($filledAttributes); + $this->assertSame('value from callback', $attributes->call('callback')->getValue()); + $this->assertSame('value from callback2', $attributes->call('callback2')->getValue()); + } + + public function testCallReturnsAnEmptyAttributeIfNoCallbackIsSetOrReturnsNull(): void + { + $attributes = new Attributes(); - $this->assertEquals(' foo="rab bar" bar="foo"', $moreAttributes->render()); + $this->assertTrue($attributes->call('callback')->isEmpty()); + + $attributes->setCallback('callback2', fn() => null); + + $this->assertTrue($attributes->call('callback2')->isEmpty()); } - public function testAttributesMergeWithCallbacks() + public function testCallThrowsInCaseCallbackFails(): void { - $attributes = Attributes::create(['foo' => 'bar']); - $callbacks = (new Attributes()) - ->registerAttributeCallback('foo', function () { - return 'rab'; - }) - ->registerAttributeCallback( - 'bar', - function () use (&$value) { - return $value; - }, - function ($v) use (&$value) { - $value = $v; - } - ); + $attributes = (new Attributes())->setCallback( + 'callback', + fn() => throw new Exception() + ); - $attributes->merge($callbacks); + $this->expectException(RuntimeException::class); + $attributes->call('callback'); + } - $attributes->set('bar', 'foo'); + public function testCallThrowsInCaseResultIsInvalid(): void + { + $attributes = (new Attributes()) + ->setCallback('callback', fn() => Attribute::createEmpty('test')); - $this->assertEquals(' foo="bar rab" bar="foo"', $attributes->render()); + $this->expectException(UnexpectedValueException::class); + $attributes->call('callback'); } - public function testClone(): void + /** + * @depends testCallReturnsACallbackResult + */ + public function testCallbacksCanBeOverridden(): void { - $original = Attributes::create([ - 'class' => 'original-class', - 'value' => 'original-value' - ]); + $attributes = (new Attributes()) + ->setCallback('callback', fn() => 'foo'); - $clone = clone $original; - $clone->get('class')->setValue('clone-class'); - $clone->get('name')->setValue('clone-name'); - $clone->remove('value'); + $this->assertSame('foo', $attributes->call('callback')->getValue()); - $cloneCone = clone $clone; - $cloneCone->get('class')->addValue('clone-clone-class'); - $cloneCone->get('name')->setValue('clone-clone-name'); - $cloneCone->get('value')->setValue('clone-clone-value'); + $attributes->setCallback('callback', fn() => 'bar'); - $this->assertSame('original-class', $original->get('class')->getValue()); - $this->assertSame('original-value', $original->get('value')->getValue()); - $this->assertNull($original->get('name')->getValue()); - $this->assertSame( - ' class="original-class" value="original-value"', - $original->render() - ); + $this->assertSame('bar', $attributes->call('callback')->getValue()); + } - $this->assertSame('clone-class', $clone->get('class')->getValue()); - $this->assertNull($clone->get('value')->getValue()); - $this->assertSame('clone-name', $clone->get('name')->getValue()); - $this->assertSame( - ' class="clone-class" name="clone-name"', - $clone->render() - ); + public function testRenderHandlesCallbackResultsCorrectly(): void + { + $attributes = (new Attributes()) + ->setCallback('callback', fn() => 'foo') + ->setCallback('callback2', fn() => null); - $this->assertSame(['clone-class', 'clone-clone-class'], $cloneCone->get('class')->getValue()); - $this->assertSame('clone-clone-name', $cloneCone->get('name')->getValue()); - $this->assertSame('clone-clone-value', $cloneCone->get('value')->getValue()); - $this->assertSame( - ' class="clone-class clone-clone-class" name="clone-clone-name" value="clone-clone-value"', - $cloneCone->render() + $this->assertSame(' callback="foo"', $attributes->render()); + } + + public function testRenderThrowsIfLegacyAndNewAttributeCallbacksConflict(): void + { + $attributes = (new Attributes()) + ->setCallback('callback', fn() => 'foo') + ->registerAttributeCallback('callback', fn() => 'bar'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Cannot use both legacy and new attribute callbacks at the same time. Offending attributes: callback' ); + + $attributes->render(); + } + + /** + * @depends testRenderHandlesCallbackResultsCorrectly + */ + public function testCallbacksAreResetUponClone(): void + { + $attributes = (new Attributes()) + ->setCallback('callback', fn() => 'foo') + ->registerAttributeCallback('callback2', fn() => 'bar'); + + $clone = clone $attributes; + + $this->assertSame('', $clone->render()); } } diff --git a/tests/HtmlElementTest.php b/tests/HtmlElementTest.php new file mode 100644 index 00000000..90aca182 --- /dev/null +++ b/tests/HtmlElementTest.php @@ -0,0 +1,20 @@ + 'bar']) + ->registerAttributeCallback('class', fn() => 'foo') + ); + + $this->assertEquals('
', $element->render()); + } +}