Skip to content

Commit 8749849

Browse files
authored
Promote assertable elements to element-specific ones (#14)
* no code * basic promotable element outline * switch promote terminology to convert elsewhere for clarity * test promote * comment * assertable form test wip * pint * assert method wip * drop format helper * asserts many * docs * promoter fallback * clean up * Revert "promoter fallback" This reverts commit b09b0be. * proper fallback test
1 parent 14557cf commit 8749849

File tree

10 files changed

+339
-8
lines changed

10 files changed

+339
-8
lines changed

src/Concerns/AssertsMany.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ziadoz\AssertableHtml\Concerns;
6+
7+
use PHPUnit\Framework\AssertionFailedError;
8+
9+
trait AssertsMany
10+
{
11+
/** Perform many PHPUnit assertions in a callback, but capture any failures into a single exception. */
12+
public function assertMany(callable $callback, ?string $message = null): static
13+
{
14+
try {
15+
$callback(...)->call($this);
16+
} catch (AssertionFailedError $assertion) {
17+
throw new AssertionFailedError(message: $message ?? $assertion->getMessage(), previous: $assertion);
18+
}
19+
20+
return $this;
21+
}
22+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ziadoz\AssertableHtml\Contracts;
6+
7+
use Dom\Element;
8+
use Dom\HTMLElement;
9+
10+
interface PromotableAssertableElement
11+
{
12+
/** Return if the HTML element should be promoted by this element-specific assertable element. */
13+
public static function shouldPromote(HTMLElement|Element $element): bool;
14+
}

src/Dom/AssertableDocument.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,13 @@ public function dd(): never
5757
/** Create an assertable document from a file. */
5858
public static function createFromFile(string $path, int $options = 0, ?string $overrideEncoding = null): self
5959
{
60-
return self::promoteErrorsToExceptions(fn () => new self(HTMLDocument::createFromFile($path, $options, $overrideEncoding)));
60+
return self::convertErrorsToExceptions(fn () => new self(HTMLDocument::createFromFile($path, $options, $overrideEncoding)));
6161
}
6262

6363
/** Create an assertable document from a string. */
6464
public static function createFromString(string $source, int $options = 0, ?string $overrideEncoding = null): self
6565
{
66-
return self::promoteErrorsToExceptions(fn () => new self(HTMLDocument::createFromString($source, $options, $overrideEncoding)));
66+
return self::convertErrorsToExceptions(fn () => new self(HTMLDocument::createFromString($source, $options, $overrideEncoding)));
6767
}
6868

6969
/** Return the assertable element matching the given selector. */
@@ -76,7 +76,7 @@ public function querySelector(string $selector): AssertableElement
7676
));
7777
}
7878

79-
return new AssertableElement($element);
79+
return new AssertableElement($element)->promote();
8080
}
8181

8282
/** Return assertable elements matching the given selector. */
@@ -95,7 +95,7 @@ public function getElementById(string $id): AssertableElement
9595
));
9696
}
9797

98-
return new AssertableElement($element);
98+
return new AssertableElement($element)->promote();
9999
}
100100

101101
/** Return assertable elements matching the given tag. */
@@ -104,14 +104,14 @@ public function getElementsByTagName(string $tag): AssertableElementsList
104104
return new AssertableElementsList($this->document->getElementsByTagName($tag));
105105
}
106106

107-
/** Promote any PHP errors that occur in the given callback to custom exceptions. */
108-
private static function promoteErrorsToExceptions(callable $callback): mixed
107+
/** Convert any PHP errors that occur in the given callback to custom exceptions. */
108+
private static function convertErrorsToExceptions(callable $callback): mixed
109109
{
110110
try {
111111
set_error_handler(function (int $severity, string $message, string $file, int $line): never {
112112
throw new UnableToCreateAssertableDocument(
113113
'Unable to create assertable HTML document.',
114-
previous: new ErrorException($message, $severity, $severity, $file, $line),
114+
previous: new ErrorException($message, 0, $severity, $file, $line),
115115
);
116116
});
117117

src/Dom/AssertableElement.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ private function getElement(): HTMLElement|Element
5757
return $this->element;
5858
}
5959

60+
/** Promote this assertable element to an element-specific equivalent assertable element, if possible. */
61+
public function promote(): static
62+
{
63+
return new AssertableElementPromoter($this->getElement())->promote() ?? $this;
64+
}
65+
6066
/** Get the assertable element HTML. */
6167
public function getHtml(): string
6268
{
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ziadoz\AssertableHtml\Dom;
6+
7+
use Dom\Element;
8+
use Dom\HTMLElement;
9+
use Ziadoz\AssertableHtml\Contracts\PromotableAssertableElement;
10+
use Ziadoz\AssertableHtml\Dom\Elements\AssertableForm;
11+
12+
final readonly class AssertableElementPromoter
13+
{
14+
private const array CUSTOM_ELEMENTS = [
15+
AssertableForm::class,
16+
];
17+
18+
/** Create a core assertable element. */
19+
public function __construct(private HTMLElement|Element $element)
20+
{
21+
}
22+
23+
/** Promote and return the first matching assertable element that matches the given HTML element. */
24+
public function promote(): (PromotableAssertableElement&AssertableElement)|null
25+
{
26+
$match = array_find(
27+
self::CUSTOM_ELEMENTS,
28+
fn (string $customElement): bool => $customElement::shouldPromote($this->element),
29+
);
30+
31+
return $match ? new $match($this->element) : null;
32+
}
33+
}

src/Dom/AssertableElementsList.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public function __construct(NodeList|HTMLCollection $nodes)
3333
{
3434
$this->elements = array_values(
3535
array_map(
36-
fn (HTMLElement|Element $element): AssertableElement => new AssertableElement($element),
36+
fn (HTMLElement|Element $element): AssertableElement => new AssertableElement($element)->promote(),
3737
$nodes instanceof NodeList
3838
? iterator_to_array($nodes)
3939
: $this->htmlCollectionToArray($nodes),
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ziadoz\AssertableHtml\Dom\Elements;
6+
7+
use Dom\Element;
8+
use Dom\HTMLElement;
9+
use Ziadoz\AssertableHtml\Concerns\AssertsMany;
10+
use Ziadoz\AssertableHtml\Contracts\PromotableAssertableElement;
11+
use Ziadoz\AssertableHtml\Dom\AssertableElement;
12+
13+
readonly class AssertableForm extends AssertableElement implements PromotableAssertableElement
14+
{
15+
use AssertsMany;
16+
17+
/*
18+
|--------------------------------------------------------------------------
19+
| Interface
20+
|--------------------------------------------------------------------------
21+
*/
22+
23+
/** {@inheritDoc} */
24+
public static function shouldPromote(Element|HTMLElement $element): bool
25+
{
26+
return $element->tagName === 'FORM';
27+
}
28+
29+
/*
30+
|--------------------------------------------------------------------------
31+
| Assert Method
32+
|--------------------------------------------------------------------------
33+
*/
34+
35+
/** Assert the form has the given method attribute. */
36+
public function assertMethod(string $method, ?string $message = null): static
37+
{
38+
$method = trim(mb_strtolower($method));
39+
40+
$this->assertAttribute(
41+
'method',
42+
fn (?string $value): bool => trim(mb_strtolower((string) $value)) === $method,
43+
$message ?? sprintf("The form method doesn't equal [%s].", $method),
44+
);
45+
46+
return $this;
47+
}
48+
49+
/** Assert the form has the GET method attribute. */
50+
public function assertMethodGet(?string $message = null): static
51+
{
52+
$this->assertMethod('get', $message);
53+
54+
return $this;
55+
}
56+
57+
/** Assert the form has the POST method attribute. */
58+
public function assertMethodPost(?string $message = null): static
59+
{
60+
$this->assertMethod('post', $message);
61+
62+
return $this;
63+
}
64+
65+
/** Assert the form has the DIALOG method attribute. */
66+
public function assertMethodDialog(?string $message = null): static
67+
{
68+
$this->assertMethod('dialog', $message);
69+
70+
return $this;
71+
}
72+
73+
/*
74+
|--------------------------------------------------------------------------
75+
| Assert Hidden Method
76+
|--------------------------------------------------------------------------
77+
*/
78+
79+
/** Assert the form has the given hidden input method. */
80+
public function assertHiddenInputMethod(string $selector, string $method, ?string $message = null): static
81+
{
82+
$this->assertMany(function () use ($selector, $method): void {
83+
$method = trim(mb_strtolower($method));
84+
85+
$this->querySelector($selector)
86+
->assertMatchesSelector('input[type="hidden"]')
87+
->assertAttribute('value', fn (?string $value): bool => trim(mb_strtolower((string) $value)) === $method);
88+
}, $message ?? sprintf("The form hidden input method doesn't equal [%s].", $method));
89+
90+
return $this;
91+
}
92+
93+
/** Assert the form has the PUT hidden input method. */
94+
public function assertMethodPut(?string $message = null): static
95+
{
96+
$this->assertHiddenInputMethod('input[type="hidden"][name="_method"]', 'put', $message);
97+
98+
return $this;
99+
}
100+
101+
/** Assert the form has the PATCH hidden input method. */
102+
public function assertMethodPatch(?string $message = null): static
103+
{
104+
$this->assertHiddenInputMethod('input[type="hidden"][name="_method"]', 'patch', $message);
105+
106+
return $this;
107+
}
108+
109+
/** Assert the form has the DELETE hidden input method. */
110+
public function assertMethodDelete(?string $message = null): static
111+
{
112+
$this->assertHiddenInputMethod('input[type="hidden"][name="_method"]', 'delete', $message);
113+
114+
return $this;
115+
}
116+
117+
/*
118+
|--------------------------------------------------------------------------
119+
| Assert Upload
120+
|--------------------------------------------------------------------------
121+
*/
122+
123+
/** Assert the form accepts uploads (has correct enctype and at least one file input. */
124+
public function assertAcceptsUpload(?string $message = null): static
125+
{
126+
$this->assertMany(function (): void {
127+
$this->assertAttribute('enctype', fn (?string $value): bool => trim(mb_strtolower((string) $value)) === 'multipart/form-data')
128+
->assertElementsCountGreaterThanOrEqual('input[type="file"]', 1);
129+
}, $message ?? "The form doesn't accept uploads.");
130+
131+
return $this;
132+
}
133+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ziadoz\AssertableHtml\Tests\Integration;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Ziadoz\AssertableHtml\Dom\AssertableDocument;
9+
10+
class AssertableFormTest extends TestCase
11+
{
12+
public function test_assertable_form(): void
13+
{
14+
// Form Methods
15+
AssertableDocument::createFromString('<form method="GET"></form>', LIBXML_HTML_NOIMPLIED)
16+
->querySelector('form')
17+
->assertMethodGet();
18+
19+
AssertableDocument::createFromString('<form method="POST"></form>', LIBXML_HTML_NOIMPLIED)
20+
->querySelector('form')
21+
->assertMethodPost();
22+
23+
AssertableDocument::createFromString('<form method="DIALOG"></form>', LIBXML_HTML_NOIMPLIED)
24+
->querySelector('form')
25+
->assertMethodDialog();
26+
27+
AssertableDocument::createFromString('<form><input type="hidden" name="_method" value="PUT"></form>', LIBXML_HTML_NOIMPLIED)
28+
->querySelector('form')
29+
->assertMethodPut();
30+
31+
AssertableDocument::createFromString('<form><input type="hidden" name="_method" value="PATCH"></form>', LIBXML_HTML_NOIMPLIED)
32+
->querySelector('form')
33+
->assertMethodPatch();
34+
35+
AssertableDocument::createFromString('<form><input type="hidden" name="_method" value="DELETE"></form>', LIBXML_HTML_NOIMPLIED)
36+
->querySelector('form')
37+
->assertMethodDelete();
38+
39+
// Form Uploads
40+
AssertableDocument::createFromString('<form enctype="multipart/form-data"><input type="file"></form>', LIBXML_HTML_NOIMPLIED)
41+
->querySelector('form')
42+
->assertAcceptsUpload();
43+
}
44+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ziadoz\AssertableHtml\Tests\Unit\Concerns\Asserts;
6+
7+
use PHPUnit\Framework\Assert as PHPUnit;
8+
use PHPUnit\Framework\AssertionFailedError;
9+
use PHPUnit\Framework\TestCase;
10+
use Ziadoz\AssertableHtml\Concerns\AssertsMany;
11+
12+
class AssertsManyTest extends TestCase
13+
{
14+
public function test_asserts_many(): void
15+
{
16+
try {
17+
$object = $this->getAssertsMany();
18+
$object->assertMany(function () {
19+
PHPUnit::assertSame('Foo', 'Bar', 'Foo is not Bar');
20+
}, 'The test assertion failed');
21+
} catch (AssertionFailedError $exception) {
22+
$this->assertSame('The test assertion failed', $exception->getMessage());
23+
$this->assertSame('Foo is not Bar' . "\n" . 'Failed asserting that two strings are identical.', $exception->getPrevious()->getMessage());
24+
}
25+
}
26+
27+
private function getAssertsMany(): object
28+
{
29+
return new class
30+
{
31+
use AssertsMany;
32+
};
33+
}
34+
}

0 commit comments

Comments
 (0)