diff --git a/.github/workflows/static-code-analysis.yml b/.github/workflows/static-code-analysis.yml index 38397a9..36dda64 100644 --- a/.github/workflows/static-code-analysis.yml +++ b/.github/workflows/static-code-analysis.yml @@ -19,5 +19,8 @@ jobs: - name: Install Composer dependencies uses: ramsey/composer-install@v2 + - name: Install PHPUnit + run: composer test -- --version + - name: Run PHPStan run: composer static-analysis diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index e6f3aea..3435a7c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3'] + php-version: ['7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3'] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 57b3de6..148f4a5 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -20,5 +20,5 @@ - + diff --git a/README.md b/README.md index 8915add..497b424 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,13 @@ class MyUnitTest extends TestCase To add PHPUnit Markup Assertions to your project, first install the library via Composer: ```sh -$ composer require --dev stevegrunwell/phpunit-markup-assertions +composer require --dev stevegrunwell/phpunit-markup-assertions +``` + +Please note that if you need to execute these against a PHP 5.6 codebase, you'll need to use version 1.x: + +```sh +composer require --dev stevegrunwell/phpunit-markup-assertions:^1.0 ``` Next, import the `SteveGrunwell\PHPUnit_Markup_Assertions\MarkupAssertionsTrait` trait into each test case that will leverage the assertions: diff --git a/composer.json b/composer.json index 0e10181..7e6f417 100644 --- a/composer.json +++ b/composer.json @@ -15,9 +15,9 @@ "source": "https://github.com/stevegrunwell/phpunit-markup-assertions/" }, "require": { - "php": "^5.6 || ^7.0 || ^8.0", - "symfony/css-selector": "^3.4|^4.4|^5.4|^6.0", - "symfony/dom-crawler": "^3.4|^4.4|^5.4|^6.0" + "php": "^7.0 || ^8.0", + "symfony/css-selector": "^3.4|^4.4|^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^3.4|^4.4|^5.4|^6.0|^7.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b980ecc..c6e358c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,3 +5,5 @@ parameters: - tests excludePaths: - tests/coverage + ignoreErrors: + - '#Method Tests\\.+ has no return type specified.#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 86843a1..6d2f59f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,7 +10,10 @@ stopOnFailure="false"> - ./tests + ./tests/Unit + + + ./tests/Integration diff --git a/src/Constraints/ContainsSelector.php b/src/Constraints/ContainsSelector.php new file mode 100644 index 0000000..6c8f67e --- /dev/null +++ b/src/Constraints/ContainsSelector.php @@ -0,0 +1,54 @@ +selector = $selector; + } + + /** + * {@inheritDoc} + * + * @return string + */ + public function toString(): string + { + return 'contains selector ' . $this->exportValue($this->selector->getValue()); + } + + /** + * {@inheritDoc} + * + * @param mixed $html The HTML to evaluate. + * + * @return bool + */ + protected function matches($html): bool + { + $dom = new DOM($html); + + return $dom->countInstancesOfSelector($this->selector) > 0; + } +} diff --git a/src/Constraints/ElementContainsString.php b/src/Constraints/ElementContainsString.php new file mode 100644 index 0000000..885984f --- /dev/null +++ b/src/Constraints/ElementContainsString.php @@ -0,0 +1,151 @@ + + */ + protected $matchingElements = []; + + /** + * @var Selector + */ + protected $selector; + + /** + * @var bool + */ + private $ignore_case; + + /** + * @var string + */ + private $needle; + + /** + * @param Selector $selector The query selector. + * @param string $needle The string to search for within the matching element(s). + * @param bool $ignore_case Optional. If true, search in a case-insensitive fashion. + * Default is false (case-sensitive searching). + */ + public function __construct(Selector $selector, $needle, $ignore_case = false) + { + parent::__construct(); + + $this->selector = $selector; + $this->needle = $needle; + $this->ignore_case = $ignore_case; + } + + /** + * {@inheritDoc} + * + * @return string + */ + public function toString(): string + { + return sprintf( + '%s string %s', + count($this->matchingElements) >= 2 ? 'contain' : 'contains', + $this->exportValue($this->needle) + ); + } + + /** + * Return additional failure description where needed. + * + * The function can be overridden to provide additional failure + * information like a diff + * + * @param mixed $other evaluated value or object + */ + protected function additionalFailureDescription($other): string + { + if (empty($this->matchingElements)) { + return ''; + } + + return sprintf( + "%s\n%s", + count($this->matchingElements) >= 2 ? 'Matching elements:' : 'Matching element:', + $this->exportMatchesArray($this->matchingElements) + ); + } + + /** + * Export an array of DOM matches for a selector. + * + * @param array $matches + * + * @return string + */ + protected function exportMatchesArray(array $matches): string + { + $matches = array_map('trim', $matches); + + return '[' . PHP_EOL . ' ' . implode(PHP_EOL . ' ', $matches) . PHP_EOL . ']'; + } + + /** + * {@inheritDoc} + * + * @param mixed $html The evaluated markup. Will not actually be used, instead replaced with + * {@see $this->matches}. + * + * @return string + */ + protected function failureDescription($html): string + { + if (empty($this->matchingElements)) { + return "any elements match selector '{$this->selector->getValue()}'"; + } + + $label = count($this->matchingElements) >= 2 + ? 'any elements matching selector %s %s' + : 'element matching selector %s %s'; + + return sprintf( + $label, + $this->exportValue($this->selector->getValue()), + $this->toString() + ); + } + + /** + * {@inheritDoc} + * + * @param mixed $html The HTML to match against. + * + * @return bool + */ + protected function matches($html): bool + { + $dom = new DOM($html); + $fn = $this->ignore_case ? 'stripos' : 'strpos'; + + // Iterate through each matching element and look for the text. + foreach ($dom->getInnerHtml($this->selector) as $html) { + if ($fn($html, $this->needle) !== false) { + return true; + } + } + + // Query again to get the outer elements for error reporting. + $this->matchingElements = $dom->getOuterHtml($this->selector); + + return false; + } +} diff --git a/src/Constraints/ElementMatchesRegExp.php b/src/Constraints/ElementMatchesRegExp.php new file mode 100644 index 0000000..d5e6287 --- /dev/null +++ b/src/Constraints/ElementMatchesRegExp.php @@ -0,0 +1,70 @@ +selector = $selector; + $this->pattern = $pattern; + } + + /** + * {@inheritDoc} + * + * @return string + */ + public function toString(): string + { + return sprintf( + '%s regular expression %s', + count($this->matchingElements) >= 2 ? 'match' : 'matches', + $this->exportValue($this->pattern) + ); + } + + /** + * Evaluates the constraint for parameter $other. Returns true if the + * constraint is met, false otherwise. + * + * @param mixed $html value or object to evaluate + * + * @return bool + */ + protected function matches($html): bool + { + $dom = new DOM($html); + + // Iterate through each matching element and look for the pattern. + foreach ($dom->getInnerHtml($this->selector) as $html) { + if (preg_match($this->pattern, $html)) { + return true; + } + } + + // Query again to get the outer elements for error reporting. + $this->matchingElements = $dom->getOuterHtml($this->selector); + + return false; + } +} diff --git a/src/Constraints/ExporterTrait.php b/src/Constraints/ExporterTrait.php new file mode 100644 index 0000000..da6bf02 --- /dev/null +++ b/src/Constraints/ExporterTrait.php @@ -0,0 +1,38 @@ +exporter()->export() in assertions. + * + * Instead, this trait exposes an exportValue() method that verifies that the relevant export + * method is present. + */ +trait ExporterTrait +{ + /** + * Exports a value as a string. + * + * @param mixed $value The value to be exported. + * + * @return string A string representation. + */ + protected function exportValue($value): string + { + // PHPUnit 8.x and newer only instantiate the exporter when needed. + if (method_exists($this, 'exporter') && $this->exporter() instanceof Exporter) { + return $this->exporter()->export($value); + } + + // PHPUnit 7.x creates the exporter in the constructor. + if (isset($this->exporter) && $this->exporter instanceof Exporter) { + return $this->exporter->export($value); + } + + // For everything else, just use var_export() and hope for the best. + return var_export($value, true); + } +} diff --git a/src/Constraints/SelectorCount.php b/src/Constraints/SelectorCount.php new file mode 100644 index 0000000..eb28ba7 --- /dev/null +++ b/src/Constraints/SelectorCount.php @@ -0,0 +1,66 @@ +selector = $selector; + $this->count = $count; + } + + /** + * {@inheritDoc} + * + * @return string + */ + public function toString(): string + { + return sprintf( + 'contains %d instance(s) of selector %s', + $this->count, + $this->exportValue($this->selector->getValue()) + ); + } + + /** + * Evaluates the constraint for parameter $other. Returns true if the + * constraint is met, false otherwise. + * + * @param mixed $html value or object to evaluate + * + * @return bool + */ + protected function matches($html): bool + { + $dom = new DOM($html); + + return $dom->countInstancesOfSelector($this->selector) === $this->count; + } +} diff --git a/src/DOM.php b/src/DOM.php new file mode 100644 index 0000000..52888c2 --- /dev/null +++ b/src/DOM.php @@ -0,0 +1,96 @@ +crawler = new Crawler($markup); + } + + /** + * Count the number of matches for $selector we find in $this->crawler. + * + * @param Selector $selector The query selector. + * + * @return int + */ + public function countInstancesOfSelector(Selector $selector) + { + return count($this->query($selector)); + } + + /** + * Retrieve the inner contents of elements matching the given selector. + * + * @param Selector $selector The query selector. + * + * @return array The inner contents of the matched selector. Each match is a separate + * value in the array. + */ + public function getInnerHtml(Selector $selector) + { + return $this->query($selector)->each(function ($element) { + return $element->html(); + }); + } + + /** + * Retrieve the inner contents of elements matching the given selector. + * + * @param Selector $selector The query selector. + * + * @return array The inner contents of the matched selector. Each match is a separate + * value in the array. + */ + public function getOuterHtml(Selector $selector) + { + return $this->query($selector)->each(function ($element) { + + /* + * The outerHtml() method was added in Symfony 4.4, which supports PHP 7.1.3+. + * + * @link https://symfony.com/blog/new-in-symfony-4-4-new-domcrawler-methods + */ + if (method_exists($element, 'outerHtml')) { + return $element->outerHtml(); + } + + // Fallback for PHP 7.0. + $node = $element->getNode(0); + + return $node->ownerDocument->saveHtml($node); + }); + } + + /** + * @param Selector $selector The query selector. + * + * @return Crawler A filtered version of $this->crawler. + */ + public function query(Selector $selector) + { + try { + return $this->crawler->filter($selector->getValue()); + } catch (SyntaxErrorException $e) { + throw new SelectorException($e->getMessage(), $e->getCode(), $e); + } + } +} diff --git a/src/Exceptions/AttributeArrayException.php b/src/Exceptions/AttributeArrayException.php new file mode 100644 index 0000000..8b55566 --- /dev/null +++ b/src/Exceptions/AttributeArrayException.php @@ -0,0 +1,10 @@ + $selector A query selector to search for. + * @param string $markup The output that should contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertContainsSelector($selector, $markup = '', $message = '') - { - $results = $this->executeDomQuery($markup, $selector); - - $this->assertGreaterThan(0, count($results), $message); + public function assertContainsSelector( + $selector, + string $markup = '', + string $message = '' + ) { + $constraint = new ContainsSelector(new Selector($selector)); + + static::assertThat($markup, $constraint, $message); } /** @@ -37,17 +43,20 @@ public function assertContainsSelector($selector, $markup = '', $message = '') * * @since 1.0.0 * - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should not contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should not contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertNotContainsSelector($selector, $markup = '', $message = '') - { - $results = $this->executeDomQuery($markup, $selector); - - $this->assertEquals(0, count($results), $message); + public function assertNotContainsSelector( + $selector, + string $markup = '', + string $message = '' + ) { + $constraint = new LogicalNot(new ContainsSelector(new Selector($selector))); + + static::assertThat($markup, $constraint, $message); } /** @@ -55,18 +64,22 @@ public function assertNotContainsSelector($selector, $markup = '', $message = '' * * @since 1.0.0 * - * @param int $count The number of matching elements expected. - * @param string $selector A query selector for the element to find. - * @param string $markup The markup to run the assertion against. - * @param string $message A message to display if the assertion fails. + * @param int $count The number of matching elements expected. + * @param string|array $selector A query selector to search for. + * @param string $markup The markup to run the assertion against. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertSelectorCount($count, $selector, $markup = '', $message = '') - { - $results = $this->executeDomQuery($markup, $selector); - - $this->assertCount($count, $results, $message); + public function assertSelectorCount( + int $count, + $selector, + string $markup = '', + string $message = '' + ) { + $constraint = new SelectorCount(new Selector($selector), $count); + + static::assertThat($markup, $constraint, $message); } /** @@ -82,13 +95,14 @@ public function assertSelectorCount($count, $selector, $markup = '', $message = * * @return void */ - public function assertHasElementWithAttributes($attributes = [], $markup = '', $message = '') - { - $this->assertContainsSelector( - '*' . $this->flattenAttributeArray($attributes), - $markup, - $message - ); + public function assertHasElementWithAttributes( + array $attributes = [], + string $markup = '', + string $message = '' + ) { + $constraint = new ContainsSelector(new Selector($attributes)); + + static::assertThat($markup, $constraint, $message); } /** @@ -104,13 +118,14 @@ public function assertHasElementWithAttributes($attributes = [], $markup = '', $ * * @return void */ - public function assertNotHasElementWithAttributes($attributes = [], $markup = '', $message = '') - { - $this->assertNotContainsSelector( - '*' . $this->flattenAttributeArray($attributes), - $markup, - $message - ); + public function assertNotHasElementWithAttributes( + $attributes = [], + $markup = '', + $message = '' + ) { + $constraint = new LogicalNot(new ContainsSelector(new Selector($attributes))); + + static::assertThat($markup, $constraint, $message); } /** @@ -118,24 +133,22 @@ public function assertNotHasElementWithAttributes($attributes = [], $markup = '' * * @since 1.1.0 * - * @param string $contents The string to look for within the DOM node's contents. - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string $contents The string to look for within the DOM node's contents. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertElementContains($contents, $selector = '', $markup = '', $message = '') - { - $method = method_exists($this, 'assertStringContainsString') - ? 'assertStringContainsString' - : 'assertContains'; // @codeCoverageIgnore - - $this->$method( - $contents, - $this->getInnerHtmlOfMatchedElements($markup, $selector), - $message - ); + public function assertElementContains( + string $contents, + $selector = '', + string $markup = '', + string $message = '' + ) { + $constraint = new ElementContainsString(new Selector($selector), $contents); + + static::assertThat($markup, $constraint, $message); } /** @@ -143,24 +156,22 @@ public function assertElementContains($contents, $selector = '', $markup = '', $ * * @since 1.1.0 * - * @param string $contents The string to look for within the DOM node's contents. - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should not contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string $contents The string to look for within the DOM node's contents. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should not contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertElementNotContains($contents, $selector = '', $markup = '', $message = '') - { - $method = method_exists($this, 'assertStringNotContainsString') - ? 'assertStringNotContainsString' - : 'assertNotContains'; // @codeCoverageIgnore - - $this->$method( - $contents, - $this->getInnerHtmlOfMatchedElements($markup, $selector), - $message - ); + public function assertElementNotContains( + string $contents, + $selector = '', + string $markup = '', + string $message = '' + ) { + $constraint = new LogicalNot(new ElementContainsString(new Selector($selector), $contents)); + + static::assertThat($markup, $constraint, $message); } /** @@ -168,24 +179,22 @@ public function assertElementNotContains($contents, $selector = '', $markup = '' * * @since 1.1.0 * - * @param string $regexp The regular expression pattern to look for within the DOM node. - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string $regexp The regular expression pattern to look for within the DOM node. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertElementRegExp($regexp, $selector = '', $markup = '', $message = '') - { - $method = method_exists($this, 'assertMatchesRegularExpression') - ? 'assertMatchesRegularExpression' - : 'assertRegExp'; // @codeCoverageIgnore - - $this->$method( - $regexp, - $this->getInnerHtmlOfMatchedElements($markup, $selector), - $message - ); + public function assertElementRegExp( + string $regexp, + $selector = '', + string $markup = '', + string $message = '' + ) { + $constraint = new ElementMatchesRegExp(new Selector($selector), $regexp); + + static::assertThat($markup, $constraint, $message); } /** @@ -193,95 +202,21 @@ public function assertElementRegExp($regexp, $selector = '', $markup = '', $mess * * @since 1.1.0 * - * @param string $regexp The regular expression pattern to look for within the DOM node. - * @param string $selector A query selector for the element to find. - * @param string $markup The output that should not contain the $selector. - * @param string $message A message to display if the assertion fails. + * @param string $regexp The regular expression pattern to look for within the DOM node. + * @param string|array $selector A query selector to search for. + * @param string $markup The output that should not contain the $selector. + * @param string $message A message to display if the assertion fails. * * @return void */ - public function assertElementNotRegExp($regexp, $selector = '', $markup = '', $message = '') - { - $method = method_exists($this, 'assertDoesNotMatchRegularExpression') - ? 'assertDoesNotMatchRegularExpression' - : 'assertNotRegExp'; // @codeCoverageIgnore - - $this->$method( - $regexp, - $this->getInnerHtmlOfMatchedElements($markup, $selector), - $message - ); - } - - /** - * Build a new DOMDocument from the given markup, then execute a query against it. - * - * @since 1.0.0 - * - * @param string $markup The HTML for the DOMDocument. - * @param string $query The DOM selector query. - * - * @return Crawler - */ - private function executeDomQuery($markup, $query) - { - $dom = new Crawler($markup); - - return $dom->filter($query); - } - - /** - * Given an array of HTML attributes, flatten them into a XPath attribute selector. - * - * @since 1.0.0 - * - * @throws RiskyTestError When the $attributes array is empty. - * - * @param array $attributes HTML attributes and their values. - * - * @return string A XPath attribute query selector. - */ - private function flattenAttributeArray(array $attributes) - { - if (empty($attributes)) { - throw new RiskyTestError('Attributes array is empty.'); - } - - array_walk($attributes, function (&$value, $key) { - // Boolean attributes. - if (null === $value) { - $value = sprintf('[%s]', $key); - } else { - $value = sprintf('[%s="%s"]', $key, htmlspecialchars($value)); - } - }); - - return implode('', $attributes); - } - - /** - * Given HTML markup and a DOM selector query, collect the innerHTML of the matched selectors. - * - * @since 1.1.0 - * - * @param string $markup The HTML for the DOMDocument. - * @param string $query The DOM selector query. - * - * @return string The concatenated innerHTML of any matched selectors. - */ - private function getInnerHtmlOfMatchedElements($markup, $query) - { - $results = $this->executeDomQuery($markup, $query); - $contents = []; - - // Loop through results and collect their innerHTML values. - foreach ($results as $result) { - $document = new \DOMDocument(); - $document->appendChild($document->importNode($result->firstChild, true)); - - $contents[] = trim(html_entity_decode($document->saveHTML())); - } - - return implode(PHP_EOL, $contents); + public function assertElementNotRegExp( + string $regexp, + $selector = '', + string $markup = '', + string $message = '' + ) { + $constraint = new LogicalNot(new ElementMatchesRegExp(new Selector($selector), $regexp)); + + static::assertThat($markup, $constraint, $message); } } diff --git a/src/Selector.php b/src/Selector.php new file mode 100644 index 0000000..f4a58c4 --- /dev/null +++ b/src/Selector.php @@ -0,0 +1,81 @@ + $selector Either a CSS selector string or an array of + * attributes, the latter of which will be flattened + * automatically into an XPath attribute query. + */ + public function __construct($selector) + { + if (is_array($selector)) { + $selector = $this->attributeArrayToString($selector); + } + + $this->selector = $selector; + } + + /** + * Magic method to enable Selectors to be cast to strings. + * + * @return string + */ + public function __toString(): string + { + return $this->getValue(); + } + + /** + * Retrieve the selector string. + * + * @return string + */ + public function getValue(): string + { + return $this->selector; + } + + /** + * Given an array of attributes, flatten them into a CSS-compatible syntax. + * + * @param array $attributes An array of attributes to flatten into an XPath + * attribute query path. + * + * @return string The flattened attribute array. + */ + private function attributeArrayToString(array $attributes): string + { + if (empty($attributes)) { + throw new AttributeArrayException('Attributes array is empty.'); + } + + array_walk($attributes, function (&$value, $key) { + // Boolean attributes. + if (null === $value) { + $value = sprintf('[%s]', $key); + } else { + $value = sprintf('[%s="%s"]', $key, htmlspecialchars($value)); + } + }); + + return '*' . implode('', $attributes); + } +} diff --git a/tests/Integration/AssertionsTest.php b/tests/Integration/AssertionsTest.php new file mode 100644 index 0000000..77e9db0 --- /dev/null +++ b/tests/Integration/AssertionsTest.php @@ -0,0 +1,99 @@ + +

Good news, everyone!

+

According to the latest reports, + you can still test markup with PHPUnit_Markup_Assertions!

+

#TestEverything

+ +HTML; + + public function testPresenceOfSelectors() + { + $this->assertContainsSelector('main', $this->markup); + $this->assertContainsSelector('h1', $this->markup); + $this->assertContainsSelector('a.link', $this->markup); + $this->assertContainsSelector('main p', $this->markup); + $this->assertContainsSelector('h1 + p', $this->markup); + $this->assertContainsSelector('p > a', $this->markup); + $this->assertContainsSelector('a[href$="example.com"]', $this->markup); + + $this->assertNotContainsSelector('h2', $this->markup); + $this->assertNotContainsSelector('a[href="https://example.org"]', $this->markup); + $this->assertNotContainsSelector('p main', $this->markup); + + $this->assertSelectorCount(0, 'h2', $this->markup); + $this->assertSelectorCount(1, 'h1', $this->markup); + $this->assertSelectorCount(2, 'p', $this->markup); + + $this->assertHasElementWithAttributes(['href' => 'https://example.com'], $this->markup); + + $this->assertNotHasElementWithAttributes( + ['href' => 'https://example.org'], + $this->markup, + 'URL uses .com, not .org.' + ); + } + + public function testMatchingContentsOfSelectors() + { + $this->assertElementContains('Good news', 'main', $this->markup); + $this->assertElementContains('Good news', 'h1', $this->markup); + $this->assertElementContains('#TestEverything', 'p', $this->markup); + $this->assertElementContains('class="link"', 'p', $this->markup); + $this->assertElementContains('#TestEverything', 'main *:last-child', $this->markup); + + $this->assertElementNotContains('good news', 'h1', $this->markup, 'Case-sensitive by default.'); + $this->assertElementNotContains( + '#TestEverything', + 'p:first-child', + $this->markup, + '#TestEverything is in the second paragraph' + ); + $this->assertElementNotContains( + 'class="link"', + 'a', + $this->markup, + 'The class is part of the outer, not inner HTML' + ); + + $this->assertElementRegExp('/\w+ news/', 'h1', $this->markup); + $this->assertElementRegExp('/GOOD NEWS/i', 'h1', $this->markup); + $this->assertElementRegExp('/latest reports/', 'p > a', $this->markup); + + $this->assertElementNotRegExp( + '/\w+ news/', + 'p', + $this->markup, + 'This text is in the heading, not the paragraph.' + ); + $this->assertElementNotRegExp( + '/#TESTEVERYTHING/', + 'p', + $this->markup, + 'No case-insensitive flag' + ); + } +} diff --git a/tests/MarkupAssertionsTraitTest.php b/tests/MarkupAssertionsTraitTest.php deleted file mode 100644 index 0cacc7f..0000000 --- a/tests/MarkupAssertionsTraitTest.php +++ /dev/null @@ -1,381 +0,0 @@ -assertContainsSelector( - $selector, - 'Example' - ); - } - - /** - * @test - * @testdox assertContainsSelector() should pick up multiple instances of a selector - */ - public function assertContainsSelector_should_pick_up_multiple_instances() - { - $this->assertContainsSelector( - 'a', - 'Home | About | Contact' - ); - } - - /** - * @test - * @testdox assertNotContainsSelector() should verify that the given selector does not exist - * @dataProvider provideSelectorVariants - */ - public function assertNotContainsSelector_should_verify_that_the_given_selector_does_not_exist($selector) - { - $this->assertNotContainsSelector( - $selector, - '

This element has little to do with the link.

' - ); - } - - /** - * @test - * @testdox assertSelectorCount() should count the instances of a selector - */ - public function assertSelectorCount_should_count_the_number_of_instances() - { - $this->assertSelectorCount( - 3, - 'li', - '
  • 1
  • 2
  • 3
' - ); - } - - /** - * @test - * @testdox assertHasElementWithAttributes() should find an element with the given attributes - */ - public function assertHasElementWithAttributes_should_find_elements_with_matching_attributes() - { - $this->assertHasElementWithAttributes( - [ - 'type' => 'email', - 'value' => 'test@example.com', - ], - '
' - ); - } - - /** - * @test - * @testdox assertHasElementWithAttributes() should be able to parse spaces in attribute values - * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/13 - */ - public function assertHasElementWithAttributes_should_be_able_to_handle_spaces() - { - $this->assertHasElementWithAttributes( - [ - 'data-attr' => 'foo bar baz', - ], - '
Contents
' - ); - } - - /** - * @test - * @testdox assertNotHasElementWithAttributes() should ensure no element has the provided attributes - */ - public function assertNotHasElementWithAttributes_should_find_no_elements_with_matching_attributes() - { - $this->assertNotHasElementWithAttributes( - [ - 'type' => 'email', - 'value' => 'test@example.com', - ], - '
' - ); - } - - /** - * @test - * @testdox assertElementContains() should be able to search for a selector - */ - public function assertElementContains_can_match_a_selector() - { - $this->assertElementContains( - 'ipsum', - '#main', - '
Lorem ipsum
Lorem ipsum
' - ); - } - - /** - * @test - * @testdox assertElementContains() should be able to chain multiple selectors - */ - public function assertElementContains_can_chain_multiple_selectors() - { - $this->assertElementContains( - 'ipsum', - '#main .foo', - '
Lorem ipsum
' - ); - } - - /** - * @test - * @testdox assertElementContains() should scope text to the selected element - */ - public function assertElementContains_should_scope_matches_to_selector() - { - $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage('The #main div does not contain the string "ipsum".'); - - $this->assertElementContains( - 'ipsum', - '#main', - '
Lorem ipsum
Foo bar baz
', - 'The #main div does not contain the string "ipsum".' - ); - } - - /** - * @test - * @testdox assertElementContains() should handle various character sets - * @dataProvider provideGreetingsInDifferentLanguages - * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/31 - */ - public function assertElementContains_should_handle_various_character_sets($greeting) - { - $this->assertElementContains( - $greeting, - 'h1', - sprintf('

%s

', $greeting) - ); - } - - /** - * @test - * @testdox assertElementNotContains() should be able to search for a selector - */ - public function assertElementNotContains_can_match_a_selector() - { - $this->assertElementNotContains( - 'ipsum', - '#main', - '
Foo bar baz
Some string
' - ); - } - - /** - * @test - * @testdox assertElementNotContains() should handle various character sets - * @dataProvider provideGreetingsInDifferentLanguages - * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/31 - */ - public function assertElementNotContains_should_handle_various_character_sets($greeting) - { - $this->assertElementNotContains( - $greeting, - 'h1', - sprintf('

Translation

%s

', $greeting) - ); - } - - /** - * @test - * @testdox assertElementRegExp() should use regular expression matching - */ - public function assertElementRegExp_should_use_regular_expression_matching() - { - $this->assertElementRegExp( - '/[A-Z0-9-]+/', - '#main', - '
Lorem ipsum
ABC123
' - ); - } - - /** - * @test - * @testdox assertElementRegExp() should be able to search for nested contents - */ - public function assertElementRegExp_should_be_able_to_match_nested_contents() - { - $this->assertElementRegExp( - '/[A-Z]+/', - '#main', - '
Lorem ipsum
ABC
' - ); - } - - /** - * @test - * @testdox assertElementNotRegExp() should use regular expression matching - */ - public function testAssertElementNotRegExp() - { - $this->assertElementNotRegExp( - '/[0-9-]+/', - '#main', - '
Foo bar baz
ABC
' - ); - } - - - /** - * @test - * @testdox flattenAttributeArray() should flatten an array of attributes - * @dataProvider provideAttributes - */ - public function flattenArrayAttribute_should_flatten_arrays_of_attributes($attributes, $expected) - { - $method = new \ReflectionMethod($this, 'flattenAttributeArray'); - $method->setAccessible(true); - - $this->assertSame($expected, $method->invoke($this, $attributes)); - } - - /** - * @test - * @testdox flattenAttributeArray() should throw a RiskyTestError if the array is empty - * @dataProvider provideAttributes - */ - public function flattenAttributeArray_should_throw_a_RiskyTestError_if_given_an_empty_array() - { - $this->expectException(RiskyTestError::class); - - $method = new \ReflectionMethod($this, 'flattenAttributeArray'); - $method->setAccessible(true); - $method->invoke($this, []); - } - - /** - * @test - * @testdox getInnerHtmlOfMatchedElements() should retrieve the inner HTML - * @dataProvider provideInnerHtml - */ - public function getInnerHtmlOfMatchedElements_should_retrieve_the_inner_HTML($markup, $selector, $expected) - { - $method = new \ReflectionMethod($this, 'getInnerHtmlOfMatchedElements'); - $method->setAccessible(true); - - $this->assertEquals($expected, $method->invoke($this, $markup, $selector)); - } - - /** - * Data provider for testFlattenAttributeArray(). - */ - public function provideAttributes() - { - return [ - 'Single attribute' => [ - [ - 'id' => 'first-name', - ], - '[id="first-name"]', - ], - 'Multiple attributes' => [ - [ - 'id' => 'first-name', - 'value' => 'Ringo', - ], - '[id="first-name"][value="Ringo"]', - ], - 'Boolean attribute' => [ - [ - 'checked' => null, - ], - '[checked]', - ], - 'Data attribute' => [ - [ - 'data-foo' => 'bar', - ], - '[data-foo="bar"]', - ], - 'Value contains quotes' => [ - [ - 'name' => 'Austin "Danger" Powers', - ], - '[name="Austin "Danger" Powers"]', - ], - ]; - } - - /** - * Data provider for testGetInnerHtmlOfMatchedElements(). - * - * @return array> - */ - public function provideInnerHtml() - { - return [ - 'A single match' => [ - 'Foo bar baz', - 'body', - 'Foo bar baz', - ], - 'Multiple matching elements' => [ - '
  • Foo
  • Bar
  • Baz
  • ', - 'li', - 'Foo' . PHP_EOL . 'Bar' . PHP_EOL . 'Baz', - ], - 'Nested elements' => [ - '

    Example site

    ', - 'h1', - 'Example site', - ], - ]; - } - - /** - * Data provider for testAssertContainsSelector(). - * - * @return array> - */ - public function provideSelectorVariants() - { - return [ - 'Simple tag name' => ['a'], - 'Class name' => ['.link'], - 'Multiple class names' => ['.link.another-class'], - 'Element ID' => ['#my-link'], - 'Tag name with class' => ['a.link'], - 'Tag name with ID' => ['a#my-link'], - 'Tag with href attribute' => ['a[href="https://example.com"]'], - ]; - } - - /** - * Provide a list of strings in various language. - * - * @return array> - */ - public function provideGreetingsInDifferentLanguages() - { - return [ - 'Arabic' => ['مرحبا!'], - 'Chinese' => ['你好'], - 'English' => ['Hello'], - 'Hebrew' => ['שלום'], - 'Japanese' => ['こんにちは'], - 'Korean' => ['안녕하십니까'], - 'Punjabi' => ['ਸਤ ਸ੍ਰੀ ਅਕਾਲ'], - 'Ukrainian' => ['Привіт'], - ]; - } -} diff --git a/tests/Unit/Constraints/ContainsSelectorTest.php b/tests/Unit/Constraints/ContainsSelectorTest.php new file mode 100644 index 0000000..bba4687 --- /dev/null +++ b/tests/Unit/Constraints/ContainsSelectorTest.php @@ -0,0 +1,78 @@ +Example'; + + $this->assertTrue($constraint->evaluate($html, '', true)); + } + + /** + * @test + * @dataProvider provideSelectorVariants + */ + public function it_should_not_find_unmatched_selectors_in_content(string $selector) + { + $constraint = new ContainsSelector(new Selector($selector)); + $html = '

    This element has little to do with the link.

    '; + + $this->assertFalse($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_fail_with_a_useful_error_message() + { + $selector = new Selector('p'); + $html = '

    Some Title

    '; + + try { + (new ContainsSelector($selector))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame( + "Failed asserting that '{$html}' contains selector '{$selector}'.", + $e->getMessage() + ); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * Data provider for testAssertContainsSelector(). + * + * @return iterable> + */ + public function provideSelectorVariants() + { + yield 'Simple tag name' => ['a']; + yield 'Class name' => ['.link']; + yield 'Multiple class names' => ['.link.another-class']; + yield 'Element ID' => ['#my-link']; + yield 'Tag name with class' => ['a.link']; + yield 'Tag name with ID' => ['a#my-link']; + yield 'Tag with href attribute' => ['a[href="https://example.com"]']; + } +} diff --git a/tests/Unit/Constraints/ElementContainsStringTest.php b/tests/Unit/Constraints/ElementContainsStringTest.php new file mode 100644 index 0000000..3354c22 --- /dev/null +++ b/tests/Unit/Constraints/ElementContainsStringTest.php @@ -0,0 +1,180 @@ +Title

    This is the content

    '; + + $this->assertTrue($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_match_in_a_case_sensitive_manner_by_default() + { + $constraint = new ElementContainsString(new Selector('p'), 'THIS IS THE CONTENT'); + $html = '

    Title

    This is the content

    '; + + $this->assertFalse( + $constraint->evaluate($html, '', true), + 'By default, searches should be case-sensitive.' + ); + } + + /** + * @test + */ + public function it_should_be_able_to_ignore_case() + { + $constraint = new ElementContainsString(new Selector('p'), 'THIS IS THE CONTENT', true); + $html = '

    Title

    This is the content

    '; + + $this->assertTrue( + $constraint->evaluate($html, '', true), + 'When $ignore_case is true, searches should be case-insensitive.' + ); + } + + /** + * @test + */ + public function it_should_fail_if_no_match_is_found() + { + $constraint = new ElementContainsString(new Selector('p'), 'This is the content'); + $html = '

    This is the content

    But this is not

    '; + + $this->assertFalse($constraint->evaluate($html, '', true)); + } + + /** + * @test + * + * @dataProvider provideGreetingsInDifferentLanguages + * + * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/31 + */ + public function it_should_be_able_to_handle_various_character_sets(string $greeting) + { + $constraint = new ElementContainsString(new Selector('h1'), $greeting); + $html = sprintf('

    %s

    ', $greeting); + + $this->assertTrue($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_test_against_the_inner_contents_of_the_found_nodes() + { + $constraint = new ElementContainsString(new Selector('p'), 'class'); + $html = '

    First

    Second

    '; + + $this->assertFalse( + $constraint->evaluate($html, '', true), + 'The string "class" does not appear in either paragraph, and thus should not be matched.' + ); + } + + /** + * @test + */ + public function it_should_fail_with_a_useful_error_message() + { + $html = '

    Some other string

    '; + $expected = <<<'MSG' +Failed asserting that element matching selector 'p' contains string 'some string'. +Matching element: +[ +

    Some other string

    +] +MSG; + + try { + (new ElementContainsString(new Selector('p'), 'some string'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * @test + */ + public function it_should_include_all_relevant_matches_in_error_messages() + { + $html = '

    Some other string

    Yet another string

    '; + $expected = <<<'MSG' +Failed asserting that any elements matching selector 'p' contain string 'some string'. +Matching elements: +[ +

    Some other string

    +

    Yet another string

    +] +MSG; + + try { + (new ElementContainsString(new Selector('p'), 'some string'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * @test + */ + public function it_should_provide_a_simple_error_message_if_no_selector_matches_are_found() + { + $html = '

    Some other string

    Yet another string

    '; + $expected = "Failed asserting that any elements match selector 'h1'."; + + try { + (new ElementContainsString(new Selector('h1'), 'some string'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * Provide a list of strings in various language. + * + * @return iterable> + */ + public function provideGreetingsInDifferentLanguages() + { + yield 'Arabic' => ['مرحبا!']; + yield 'Chinese' => ['你好']; + yield 'English' => ['Hello']; + yield 'Hebrew' => ['שלום']; + yield 'Japanese' => ['こんにちは']; + yield 'Korean' => ['안녕하십니까']; + yield 'Punjabi' => ['ਸਤ ਸ੍ਰੀ ਅਕਾਲ']; + yield 'Ukrainian' => ['Привіт']; + } +} diff --git a/tests/Unit/Constraints/ElementMatchesRegExpTest.php b/tests/Unit/Constraints/ElementMatchesRegExpTest.php new file mode 100644 index 0000000..3d88125 --- /dev/null +++ b/tests/Unit/Constraints/ElementMatchesRegExpTest.php @@ -0,0 +1,146 @@ +Title

    12345

    '; + + $this->assertTrue($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_respect_flags() + { + $constraint = new ElementMatchesRegExp(new Selector('p'), '/[A-Z]+/i'); + $html = '

    Title

    123hello456

    '; + + $this->assertTrue($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_fail_if_no_match_is_found() + { + $constraint = new ElementMatchesRegExp(new Selector('p'), '/[a-z]+/'); + $html = '

    Title

    12345

    '; + + $this->assertFalse( + $constraint->evaluate($html, '', true), + '"12345" does not match pattern /[a-z]/' + ); + } + + /** + * @test + */ + public function it_should_test_against_the_inner_contents_of_the_found_nodes() + { + $constraint = new ElementMatchesRegExp(new Selector('p'), '/class/'); + $html = '

    First

    Second

    '; + + $this->assertFalse( + $constraint->evaluate($html, '', true), + 'The string "class" does not appear in either paragraph, and thus should not be matched.' + ); + } + + /** + * @test + */ + public function it_should_scope_queries_to_the_selector() + { + $constraint = new ElementMatchesRegExp(new Selector('h1'), '/\d+/'); + $html = '

    Title

    12345

    '; + + $this->assertFalse($constraint->evaluate($html, '', true)); + } + + /** + * @test + */ + public function it_should_fail_with_a_useful_error_message() + { + $html = '

    Some other string

    '; + $expected = <<<'MSG' +Failed asserting that element matching selector 'p' matches regular expression '/some\sstring/'. +Matching element: +[ +

    Some other string

    +] +MSG; + + try { + (new ElementMatchesRegExp(new Selector('p'), '/some\sstring/'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * @test + */ + public function it_should_include_all_relevant_matches_in_error_messages() + { + $selector = new Selector('p'); + $html = '

    Some other string

    Yet another string

    '; + $expected = <<<'MSG' +Failed asserting that any elements matching selector 'p' match regular expression '/some\sstring/'. +Matching elements: +[ +

    Some other string

    +

    Yet another string

    +] +MSG; + + try { + (new ElementMatchesRegExp($selector, '/some\sstring/'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * @test + */ + public function it_should_provide_a_simple_error_message_if_no_selector_matches_are_found() + { + $html = '

    Some other string

    Yet another string

    '; + $expected = "Failed asserting that any elements match selector 'h1'."; + + try { + (new ElementMatchesRegExp(new Selector('h1'), 'some string'))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame($expected, $e->getMessage()); + return; + } + + $this->fail('Did not receive the expected error message.'); + } +} diff --git a/tests/Unit/Constraints/SelectorCountTest.php b/tests/Unit/Constraints/SelectorCountTest.php new file mode 100644 index 0000000..4edce40 --- /dev/null +++ b/tests/Unit/Constraints/SelectorCountTest.php @@ -0,0 +1,96 @@ +assertTrue($constraint->evaluate($markup, '', true)); + } + + /** + * @test + * @dataProvider provideMarkupVariants + */ + public function it_should_fail_if_it_contains_a_different_number_of_matches() + { + $markup = '

    This is the only paragraph

    '; + + $this->assertFalse((new SelectorCount(new Selector('p'), 0))->evaluate($markup, '', true)); + $this->assertFalse((new SelectorCount(new Selector('p'), 2))->evaluate($markup, '', true)); + } + + /** + * @test + */ + public function it_should_fail_with_a_useful_error_message() + { + $selector = new Selector('p.body'); + $html = '

    Some Title

    '; + + try { + (new SelectorCount($selector, 5))->evaluate($html); + } catch (\Throwable $e) { + $this->assertSame( + "Failed asserting that '{$html}' contains 5 instance(s) of selector '{$selector}'.", + $e->getMessage() + ); + return; + } + + $this->fail('Did not receive the expected error message.'); + } + + /** + * Data provider for testAssertContainsSelector(). + * + * @return iterable + */ + public function provideMarkupVariants() + { + yield 'Simple count' => [ + '

    This is a title

    Content

    ', + new Selector('h1'), + 1, + ]; + + yield 'Multiple siblings' => [ + '
    • 1
    • 2
    • 3
    ', + new Selector('li'), + 3 + ]; + + yield 'Nested elements with low specificity' => [ + '
    Hello
    There
    ', + new Selector('div'), + 2, + ]; + + yield 'Nested elements with high-specificity' => [ + '
    Hello
    There
    ', + new Selector('div>div'), + 1, + ]; + } +} diff --git a/tests/Unit/DOMTest.php b/tests/Unit/DOMTest.php new file mode 100644 index 0000000..f077205 --- /dev/null +++ b/tests/Unit/DOMTest.php @@ -0,0 +1,143 @@ +assertSame($expected, $dom->countInstancesOfSelector($selector)); + } + + /** + * @test + * @testdox getInnerHtml() should retrieve the inner HTML for each matching element. + */ + public function getInnerHtml_should_retrieve_the_inner_HTML_for_each_matching_element() + { + $markup = <<<'HTML' +
      +
    • The strong element
    • +
    • The em element
    • +
    • The kbd element
    • +
    +HTML; + $dom = new DOM($markup); + + $this->assertSame( + [ + 'The strong element', + 'The em element', + 'The kbd element', + ], + $dom->getInnerHtml(new Selector('li')) + ); + } + + /** + * @test + * @testdox getInnerHtml() should return an empty array if there are no matches + */ + public function getInnerHtml_should_return_an_empty_array_if_there_are_no_matches() + { + $dom = new DOM('

    A title

    '); + + $this->assertEmpty($dom->getInnerHtml(new Selector('h2'))); + } + + /** + * @test + * @testdox getOuterHtml() should retrieve the outer HTML for each matching element. + */ + public function getOuterHtml_should_retrieve_the_outer_HTML_for_each_matching_element() + { + $markup = <<<'HTML' +
      +
    • The strong element
    • +
    • The em element
    • +
    • The kbd element
    • +
    +HTML; + $dom = new DOM($markup); + + $this->assertSame( + [ + '
  • The strong element
  • ', + '
  • The em element
  • ', + '
  • The kbd element
  • ', + ], + $dom->getOuterHtml(new Selector('li')) + ); + } + + /** + * @test + * @testdox getOuterHtml() should return an empty array if there are no matches + */ + public function getOuterHtml_should_return_an_empty_array_if_there_are_no_matches() + { + $dom = new DOM('

    A title

    '); + + $this->assertEmpty($dom->getOuterHtml(new Selector('h2'))); + } + + /** + * @test + * @testdox query() should throw a SelectorException if the selector is invalid + */ + public function query_should_throw_a_SelectorException_if_the_selector_is_invalid() + { + $dom = new DOM('

    Some markup

    '); + $selector = new Selector('#'); + + try { + $dom->query($selector); + } catch (\Exception $e) { + $this->assertInstanceOf(SelectorException::class, $e); + return; + } + + $this->fail('Failed to catch a SelectorException from invalid selector "#".'); + } + + /** + * Return test cases with varying numbers of .inner elements. + * + * @return iterable + */ + public function provideMarkupWithInnerClass() + { + yield 'No matches' => [ + '
    ', + 0, + ]; + + yield 'One match' => [ + '
    Hello
    ', + 1, + ]; + + yield 'Two matches' => [ + '
    One
    Two
    ', + 2 + ]; + } +} diff --git a/tests/Unit/SelectorTest.php b/tests/Unit/SelectorTest.php new file mode 100644 index 0000000..b87f2f8 --- /dev/null +++ b/tests/Unit/SelectorTest.php @@ -0,0 +1,120 @@ +assertSame('a.some-class', $selector->getValue()); + } + + /** + * @test + * + * @param array $attributes + * @param string $expected + * + * @dataProvider provideAttributes + */ + public function it_should_automatically_convert_attribute_arrays_to_strings( + array $attributes, + string $expected + ) { + $selector = new Selector($attributes); + + $this->assertSame($expected, $selector->getValue()); + } + + /** + * @test + * + * @ticket https://github.com/stevegrunwell/phpunit-markup-assertions/issues/13 + */ + public function it_should_be_able_to_handle_spaces_in_attribute_values() + { + $selector = new Selector([ + 'data-attr' => 'foo bar baz', + ]); + + $this->assertSame('*[data-attr="foo bar baz"]', $selector->getValue()); + } + + /** + * @test + */ + public function it_should_throw_if_unable_to_handle_attribute_array() + { + $this->expectException(AttributeArrayException::class); + + new Selector([]); + } + + /** + * @test + */ + public function it_should_be_able_to_be_cast_to_a_string() + { + $selector = new Selector('a.some-class'); + + $this->assertSame('a.some-class', (string) $selector); + } + + /** + * Data provider for testFlattenAttributeArray(). + * + * @return iterable, string}> The attribute array and the expected string. + */ + public function provideAttributes() + { + yield 'Single attribute' => [ + [ + 'id' => 'first-name', + ], + '*[id="first-name"]', + ]; + + yield 'Multiple attributes' => [ + [ + 'id' => 'first-name', + 'value' => 'Ringo', + ], + '*[id="first-name"][value="Ringo"]', + ]; + + yield 'Boolean attribute' => [ + [ + 'checked' => null, + ], + '*[checked]', + ]; + + yield 'Data attribute' => [ + [ + 'data-foo' => 'bar', + ], + '*[data-foo="bar"]', + ]; + + yield 'Value contains quotes' => [ + [ + 'name' => 'Austin "Danger" Powers', + ], + '*[name="Austin "Danger" Powers"]', + ]; + } +}