You've already forked wp-bootstrap
Add PHPUnit test suite with 64 unit tests (v1.1.1)
PHPUnit 11 + Brain\Monkey for WordPress function mocking. Tests cover BlockRenderer (28), WidgetRenderer (9), NavWalker (14), and TemplateController (12). Includes functional WP_HTML_Tag_Processor stub, CI test job between lint and build-release, prebuild hook gating npm build on passing tests, and release package exclusions for test files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
10
tests/Stubs/WpBlock.php
Normal file
10
tests/Stubs/WpBlock.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Stub for WordPress WP_Block class.
|
||||
*
|
||||
* Used as a type hint in BlockRenderer handler methods.
|
||||
* Only needs to exist — no functionality required.
|
||||
*/
|
||||
class WP_Block
|
||||
{
|
||||
}
|
||||
135
tests/Stubs/WpHtmlTagProcessor.php
Normal file
135
tests/Stubs/WpHtmlTagProcessor.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
/**
|
||||
* Functional stub for WordPress WP_HTML_Tag_Processor.
|
||||
*
|
||||
* Uses DOMDocument/DOMXPath to provide the subset of functionality
|
||||
* used by BlockRenderer: next_tag(), add_class(), get_updated_html().
|
||||
*
|
||||
* This is NOT a complete implementation — only the methods and query
|
||||
* modes actually used in the theme are supported.
|
||||
*/
|
||||
class WP_HTML_Tag_Processor
|
||||
{
|
||||
private string $html;
|
||||
private \DOMDocument $doc;
|
||||
private \DOMXPath $xpath;
|
||||
private ?\DOMElement $current = null;
|
||||
|
||||
/** @var int[] Object IDs of already-visited elements. */
|
||||
private array $visited = [];
|
||||
|
||||
public function __construct(string $html)
|
||||
{
|
||||
$this->html = $html;
|
||||
$this->doc = new \DOMDocument();
|
||||
$this->doc->encoding = 'UTF-8';
|
||||
|
||||
// Wrap in a full HTML document so <body> is always present.
|
||||
// This ensures get_updated_html() can reliably extract content.
|
||||
@$this->doc->loadHTML(
|
||||
'<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>'
|
||||
. $html
|
||||
. '</body></html>',
|
||||
LIBXML_HTML_NODEFDTD
|
||||
);
|
||||
|
||||
$this->xpath = new \DOMXPath($this->doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to the next matching tag.
|
||||
*
|
||||
* @param string|array|null $query Tag name (string) or ['class_name' => '...'] (array).
|
||||
*/
|
||||
public function next_tag($query = null): bool
|
||||
{
|
||||
$tagName = null;
|
||||
$className = null;
|
||||
|
||||
if (is_string($query)) {
|
||||
$tagName = strtolower($query);
|
||||
} elseif (is_array($query)) {
|
||||
$tagName = isset($query['tag_name']) ? strtolower($query['tag_name']) : null;
|
||||
$className = $query['class_name'] ?? null;
|
||||
}
|
||||
|
||||
// Build XPath — search within <body> only.
|
||||
$body = $this->doc->getElementsByTagName('body')->item(0);
|
||||
if (!$body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$xpathExpr = './/*';
|
||||
$conditions = [];
|
||||
|
||||
if ($tagName !== null) {
|
||||
$conditions[] = sprintf(
|
||||
"translate(local-name(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') = '%s'",
|
||||
$tagName
|
||||
);
|
||||
}
|
||||
|
||||
if ($className !== null) {
|
||||
$conditions[] = sprintf(
|
||||
"contains(concat(' ', normalize-space(@class), ' '), ' %s ')",
|
||||
$className
|
||||
);
|
||||
}
|
||||
|
||||
if ($conditions) {
|
||||
$xpathExpr .= '[' . implode(' and ', $conditions) . ']';
|
||||
}
|
||||
|
||||
$nodes = $this->xpath->query($xpathExpr, $body);
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$oid = spl_object_id($node);
|
||||
if (!in_array($oid, $this->visited, true)) {
|
||||
$this->current = $node;
|
||||
$this->visited[] = $oid;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a CSS class to the current tag (idempotent).
|
||||
*/
|
||||
public function add_class(string $className): bool
|
||||
{
|
||||
if ($this->current === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existing = $this->current->getAttribute('class');
|
||||
$classes = $existing ? array_filter(explode(' ', $existing)) : [];
|
||||
|
||||
if (!in_array($className, $classes, true)) {
|
||||
$classes[] = $className;
|
||||
}
|
||||
|
||||
$this->current->setAttribute('class', implode(' ', $classes));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the modified HTML.
|
||||
*/
|
||||
public function get_updated_html(): string
|
||||
{
|
||||
$body = $this->doc->getElementsByTagName('body')->item(0);
|
||||
if (!$body) {
|
||||
return $this->html;
|
||||
}
|
||||
|
||||
$html = '';
|
||||
foreach ($body->childNodes as $child) {
|
||||
$html .= $this->doc->saveHTML($child);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
10
tests/Stubs/WpWidget.php
Normal file
10
tests/Stubs/WpWidget.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Stub for WordPress WP_Widget class.
|
||||
*
|
||||
* Used as a type hint in WidgetRenderer::processBlockWidgetContent().
|
||||
* Only needs to exist — no functionality required.
|
||||
*/
|
||||
class WP_Widget
|
||||
{
|
||||
}
|
||||
323
tests/Unit/Block/BlockRendererTest.php
Normal file
323
tests/Unit/Block/BlockRendererTest.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPBootstrap\Tests\Unit\Block;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use WPBootstrap\Block\BlockRenderer;
|
||||
|
||||
class BlockRendererTest extends TestCase
|
||||
{
|
||||
private BlockRenderer $renderer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// Return true so the constructor skips add_filter() registration.
|
||||
Functions\when('is_admin')->justReturn(true);
|
||||
Functions\when('wp_doing_ajax')->justReturn(false);
|
||||
|
||||
$this->renderer = new BlockRenderer();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── core/table ──────────────────────────────────────────────
|
||||
|
||||
public function testRenderTableAddsTableClass(): void
|
||||
{
|
||||
$html = '<figure class="wp-block-table"><table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table></figure>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderTable($html, $block);
|
||||
|
||||
$this->assertStringContainsString('table', $this->classesOf('table', $result));
|
||||
}
|
||||
|
||||
public function testRenderTableWithStripesAddsStripedClass(): void
|
||||
{
|
||||
$html = '<figure class="wp-block-table is-style-stripes"><table><tr><td>1</td></tr></table></figure>';
|
||||
$block = ['attrs' => ['className' => 'is-style-stripes']];
|
||||
$result = $this->renderer->renderTable($html, $block);
|
||||
|
||||
$classes = $this->classesOf('table', $result);
|
||||
$this->assertStringContainsString('table', $classes);
|
||||
$this->assertStringContainsString('table-striped', $classes);
|
||||
}
|
||||
|
||||
public function testRenderTableEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderTable('', []));
|
||||
}
|
||||
|
||||
public function testRenderTableWithoutTableTagReturnsOriginal(): void
|
||||
{
|
||||
$html = '<p>No table here</p>';
|
||||
$this->assertSame($html, $this->renderer->renderTable($html, []));
|
||||
}
|
||||
|
||||
// ── core/button ─────────────────────────────────────────────
|
||||
|
||||
public function testRenderButtonAddsBtnPrimaryByDefault(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$classes = $this->classesOf('a', $result);
|
||||
$this->assertStringContainsString('btn', $classes);
|
||||
$this->assertStringContainsString('btn-primary', $classes);
|
||||
}
|
||||
|
||||
public function testRenderButtonWithBackgroundColor(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => ['backgroundColor' => 'danger']];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$classes = $this->classesOf('a', $result);
|
||||
$this->assertStringContainsString('btn', $classes);
|
||||
$this->assertStringContainsString('btn-danger', $classes);
|
||||
}
|
||||
|
||||
public function testRenderButtonOutlineStyle(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button is-style-outline"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => ['className' => 'is-style-outline', 'textColor' => 'success']];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$classes = $this->classesOf('a', $result);
|
||||
$this->assertStringContainsString('btn', $classes);
|
||||
$this->assertStringContainsString('btn-outline-success', $classes);
|
||||
}
|
||||
|
||||
public function testRenderButtonOutlineDefaultsToPrimary(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button is-style-outline"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => ['className' => 'is-style-outline']];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$this->assertStringContainsString('btn-outline-primary', $this->classesOf('a', $result));
|
||||
}
|
||||
|
||||
public function testRenderButtonWithGradientOnlyAddsBtnBase(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => ['gradient' => 'vivid-cyan-blue']];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$classes = $this->classesOf('a', $result);
|
||||
$this->assertStringContainsString('btn', $classes);
|
||||
$this->assertStringNotContainsString('btn-primary', $classes);
|
||||
}
|
||||
|
||||
public function testRenderButtonWithUnknownColorDefaultsToPrimary(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => ['backgroundColor' => 'custom-teal']];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$this->assertStringContainsString('btn-primary', $this->classesOf('a', $result));
|
||||
}
|
||||
|
||||
public function testRenderButtonEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderButton('', []));
|
||||
}
|
||||
|
||||
// ── core/buttons ────────────────────────────────────────────
|
||||
|
||||
public function testRenderButtonsAddsFlexClasses(): void
|
||||
{
|
||||
$html = '<div class="wp-block-buttons"><div class="wp-block-button"><a class="wp-block-button__link" href="#">A</a></div></div>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderButtons($html, $block);
|
||||
|
||||
$classes = $this->classesOfFirst('.wp-block-buttons', $result);
|
||||
$this->assertStringContainsString('d-flex', $classes);
|
||||
$this->assertStringContainsString('flex-wrap', $classes);
|
||||
$this->assertStringContainsString('gap-2', $classes);
|
||||
}
|
||||
|
||||
public function testRenderButtonsEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderButtons('', []));
|
||||
}
|
||||
|
||||
// ── core/image ──────────────────────────────────────────────
|
||||
|
||||
public function testRenderImageAddsImgFluid(): void
|
||||
{
|
||||
$html = '<figure class="wp-block-image"><img src="photo.jpg" alt="Photo"></figure>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderImage($html, $block);
|
||||
|
||||
$this->assertStringContainsString('img-fluid', $this->classesOf('img', $result));
|
||||
}
|
||||
|
||||
public function testRenderImageEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderImage('', []));
|
||||
}
|
||||
|
||||
// ── core/search ─────────────────────────────────────────────
|
||||
|
||||
public function testRenderSearchAddsBootstrapClasses(): void
|
||||
{
|
||||
$html = '<form class="wp-block-search" role="search">'
|
||||
. '<div class="wp-block-search__inside-wrapper">'
|
||||
. '<input class="wp-block-search__input" type="search" placeholder="Search">'
|
||||
. '<button class="wp-block-search__button" type="submit">Search</button>'
|
||||
. '</div></form>';
|
||||
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderSearch($html, $block);
|
||||
|
||||
$this->assertStringContainsString('input-group', $result);
|
||||
$this->assertStringContainsString('form-control', $result);
|
||||
$this->assertStringContainsString('btn-primary', $result);
|
||||
}
|
||||
|
||||
public function testRenderSearchEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderSearch('', []));
|
||||
}
|
||||
|
||||
// ── core/quote ──────────────────────────────────────────────
|
||||
|
||||
public function testRenderQuoteAddsBlockquoteClass(): void
|
||||
{
|
||||
$html = '<blockquote class="wp-block-quote"><p>Quote text</p></blockquote>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderQuote($html, $block);
|
||||
|
||||
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
|
||||
}
|
||||
|
||||
public function testRenderQuoteWithCiteAddsFooterClass(): void
|
||||
{
|
||||
$html = '<blockquote class="wp-block-quote"><p>Quote</p><cite>Author</cite></blockquote>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderQuote($html, $block);
|
||||
|
||||
$this->assertStringContainsString('blockquote-footer', $result);
|
||||
}
|
||||
|
||||
public function testRenderQuoteWithoutCite(): void
|
||||
{
|
||||
$html = '<blockquote class="wp-block-quote"><p>Quote only</p></blockquote>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderQuote($html, $block);
|
||||
|
||||
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
|
||||
$this->assertStringNotContainsString('blockquote-footer', $result);
|
||||
}
|
||||
|
||||
public function testRenderQuoteEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderQuote('', []));
|
||||
}
|
||||
|
||||
// ── core/pullquote ──────────────────────────────────────────
|
||||
|
||||
public function testRenderPullquoteAddsBlockquoteClass(): void
|
||||
{
|
||||
$html = '<figure class="wp-block-pullquote"><blockquote><p>Pull</p></blockquote></figure>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderPullquote($html, $block);
|
||||
|
||||
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
|
||||
}
|
||||
|
||||
public function testRenderPullquoteWithCiteAddsFooterClass(): void
|
||||
{
|
||||
$html = '<figure class="wp-block-pullquote"><blockquote><p>Pull</p><cite>Source</cite></blockquote></figure>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderPullquote($html, $block);
|
||||
|
||||
$this->assertStringContainsString('blockquote-footer', $result);
|
||||
}
|
||||
|
||||
public function testRenderPullquoteEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderPullquote('', []));
|
||||
}
|
||||
|
||||
// ── core/list ───────────────────────────────────────────────
|
||||
|
||||
public function testRenderListGroupAddsClasses(): void
|
||||
{
|
||||
$html = '<ul class="is-style-list-group"><li>A</li><li>B</li><li>C</li></ul>';
|
||||
$block = ['attrs' => ['className' => 'is-style-list-group']];
|
||||
$result = $this->renderer->renderList($html, $block);
|
||||
|
||||
$this->assertStringContainsString('list-group', $this->classesOf('ul', $result));
|
||||
$this->assertStringContainsString('list-group-item', $result);
|
||||
}
|
||||
|
||||
public function testRenderListGroupOrderedList(): void
|
||||
{
|
||||
$html = '<ol class="is-style-list-group"><li>1</li><li>2</li></ol>';
|
||||
$block = ['attrs' => ['className' => 'is-style-list-group', 'ordered' => true]];
|
||||
$result = $this->renderer->renderList($html, $block);
|
||||
|
||||
$this->assertStringContainsString('list-group', $this->classesOf('ol', $result));
|
||||
}
|
||||
|
||||
public function testRenderListWithoutGroupStyleReturnsUnchanged(): void
|
||||
{
|
||||
$html = '<ul><li>A</li></ul>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderList($html, $block);
|
||||
|
||||
$this->assertStringNotContainsString('list-group', $result);
|
||||
}
|
||||
|
||||
public function testRenderListEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderList('', []));
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the class attribute value of the first matching tag.
|
||||
*/
|
||||
private function classesOf(string $tagName, string $html): string
|
||||
{
|
||||
$doc = new \DOMDocument();
|
||||
@$doc->loadHTML('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>', LIBXML_HTML_NODEFDTD);
|
||||
$elements = $doc->getElementsByTagName($tagName);
|
||||
|
||||
return $elements->length > 0 ? ($elements->item(0)->getAttribute('class') ?? '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract classes from the first element matching a CSS selector-style class.
|
||||
*/
|
||||
private function classesOfFirst(string $selector, string $html): string
|
||||
{
|
||||
$className = ltrim($selector, '.');
|
||||
$doc = new \DOMDocument();
|
||||
@$doc->loadHTML('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>', LIBXML_HTML_NODEFDTD);
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$body = $doc->getElementsByTagName('body')->item(0);
|
||||
if (!$body) {
|
||||
return '';
|
||||
}
|
||||
$nodes = $xpath->query(sprintf(
|
||||
".//*[contains(concat(' ', normalize-space(@class), ' '), ' %s ')]",
|
||||
$className
|
||||
), $body);
|
||||
|
||||
return $nodes->length > 0 ? ($nodes->item(0)->getAttribute('class') ?? '') : '';
|
||||
}
|
||||
}
|
||||
173
tests/Unit/Block/WidgetRendererTest.php
Normal file
173
tests/Unit/Block/WidgetRendererTest.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPBootstrap\Tests\Unit\Block;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use WPBootstrap\Block\WidgetRenderer;
|
||||
|
||||
class WidgetRendererTest extends TestCase
|
||||
{
|
||||
private WidgetRenderer $renderer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// Skip constructor filter registration.
|
||||
Functions\when('is_admin')->justReturn(true);
|
||||
Functions\when('wp_doing_ajax')->justReturn(false);
|
||||
|
||||
$this->renderer = new WidgetRenderer();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── wrapWidgetInCard ────────────────────────────────────────
|
||||
|
||||
public function testWrapWidgetInCardSetsCardStructure(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = $this->makeParams('block-2', 'widget mb-4 widget_block');
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
$this->assertStringContainsString('card', $result[0]['before_widget']);
|
||||
$this->assertStringContainsString('card-body', $result[0]['before_widget']);
|
||||
$this->assertSame('</div></div>', $result[0]['after_widget']);
|
||||
}
|
||||
|
||||
public function testWrapWidgetInCardSetsCardTitle(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = $this->makeParams('block-3', 'widget mb-4 widget_block');
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
$this->assertStringContainsString('<h4', $result[0]['before_title']);
|
||||
$this->assertStringContainsString('card-title', $result[0]['before_title']);
|
||||
$this->assertSame('</h4>', $result[0]['after_title']);
|
||||
}
|
||||
|
||||
public function testWrapWidgetInCardPreservesWidgetId(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = $this->makeParams('search-1', 'widget mb-4 widget_search');
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
$this->assertStringContainsString('id="search-1"', $result[0]['before_widget']);
|
||||
}
|
||||
|
||||
public function testWrapWidgetInCardPreservesWidgetTypeClasses(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = $this->makeParams('block-5', 'widget mb-4 widget_block wp-block-heading');
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
// widget_block and wp-block-heading are kept; widget and mb-4 are removed.
|
||||
$this->assertStringContainsString('widget_block', $result[0]['before_widget']);
|
||||
$this->assertStringContainsString('wp-block-heading', $result[0]['before_widget']);
|
||||
}
|
||||
|
||||
public function testWrapWidgetInCardFiltersGenericClasses(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = $this->makeParams('block-2', 'widget mb-4 widget_block');
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
// The card wrapper adds its own "widget" class, but the extracted
|
||||
// classes should not include the generic "widget" or "mb-4".
|
||||
// Count occurrences: "card mb-3 widget " + "widget_block" should have exactly one "widget".
|
||||
preg_match_all('/\bwidget\b/', $result[0]['before_widget'], $matches);
|
||||
$this->assertCount(1, $matches[0], 'Generic "widget" class should appear once (from card wrapper), not duplicated');
|
||||
}
|
||||
|
||||
public function testWrapWidgetInCardWithNoExistingClasses(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = [
|
||||
[
|
||||
'widget_id' => 'text-1',
|
||||
'before_widget' => '<div id="text-1">',
|
||||
'after_widget' => '</div>',
|
||||
'before_title' => '<h3>',
|
||||
'after_title' => '</h3>',
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
$this->assertStringContainsString('card', $result[0]['before_widget']);
|
||||
$this->assertStringContainsString('id="text-1"', $result[0]['before_widget']);
|
||||
}
|
||||
|
||||
// ── processBlockWidgetContent ───────────────────────────────
|
||||
|
||||
public function testProcessBlockWidgetContentReplacesH2WithH4(): void
|
||||
{
|
||||
$content = '<h2 class="wp-block-heading">Categories</h2><ul><li>Cat A</li></ul>';
|
||||
$widget = new \WP_Widget();
|
||||
|
||||
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
|
||||
|
||||
$this->assertStringContainsString('<h4 class="wp-block-heading">', $result);
|
||||
$this->assertStringContainsString('</h4>', $result);
|
||||
$this->assertStringNotContainsString('<h2', $result);
|
||||
$this->assertStringNotContainsString('</h2>', $result);
|
||||
}
|
||||
|
||||
public function testProcessBlockWidgetContentPreservesOtherH2(): void
|
||||
{
|
||||
$content = '<h2 class="custom-heading">Keep me</h2>';
|
||||
$widget = new \WP_Widget();
|
||||
|
||||
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
|
||||
|
||||
// h2 without wp-block-heading class should remain.
|
||||
$this->assertStringContainsString('<h2 class="custom-heading">', $result);
|
||||
}
|
||||
|
||||
public function testProcessBlockWidgetContentEmptyReturnsEmpty(): void
|
||||
{
|
||||
$widget = new \WP_Widget();
|
||||
$this->assertSame('', $this->renderer->processBlockWidgetContent('', [], $widget));
|
||||
}
|
||||
|
||||
public function testProcessBlockWidgetContentMultipleH2(): void
|
||||
{
|
||||
$content = '<h2 class="wp-block-heading">First</h2><p>Text</p><h2 class="wp-block-heading">Second</h2>';
|
||||
$widget = new \WP_Widget();
|
||||
|
||||
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
|
||||
|
||||
$this->assertSame(0, substr_count($result, '<h2'));
|
||||
$this->assertSame(2, substr_count($result, '<h4'));
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────
|
||||
|
||||
private function makeParams(string $widgetId, string $classes): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'widget_id' => $widgetId,
|
||||
'before_widget' => sprintf('<div id="%s" class="%s">', $widgetId, $classes),
|
||||
'after_widget' => '</div>',
|
||||
'before_title' => '<h3 class="sidebar-heading">',
|
||||
'after_title' => '</h3>',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
224
tests/Unit/Template/NavWalkerTest.php
Normal file
224
tests/Unit/Template/NavWalkerTest.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPBootstrap\Tests\Unit\Template;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use WPBootstrap\Template\NavWalker;
|
||||
|
||||
class NavWalkerTest extends TestCase
|
||||
{
|
||||
private NavWalker $walker;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// Default: no page or category is active.
|
||||
Functions\when('is_page')->justReturn(false);
|
||||
Functions\when('is_category')->justReturn(false);
|
||||
|
||||
$this->walker = new NavWalker();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Tree structure ──────────────────────────────────────────
|
||||
|
||||
public function testBuildTreeWithEmptyArray(): void
|
||||
{
|
||||
$this->assertSame([], $this->walker->buildTree([]));
|
||||
}
|
||||
|
||||
public function testBuildTreeWithSingleItem(): void
|
||||
{
|
||||
$items = [$this->makeItem(10, 'Home', '/')];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertCount(1, $tree);
|
||||
$this->assertSame('Home', $tree[0]['title']);
|
||||
$this->assertSame('/', $tree[0]['url']);
|
||||
}
|
||||
|
||||
public function testBuildTreeFlatItemsAllTopLevel(): void
|
||||
{
|
||||
$items = [
|
||||
$this->makeItem(1, 'A', '/a'),
|
||||
$this->makeItem(2, 'B', '/b'),
|
||||
$this->makeItem(3, 'C', '/c'),
|
||||
];
|
||||
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertCount(3, $tree);
|
||||
foreach ($tree as $node) {
|
||||
$this->assertEmpty($node['children']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testBuildTreeWithChildren(): void
|
||||
{
|
||||
$items = [
|
||||
$this->makeItem(10, 'Home', '/'),
|
||||
$this->makeItem(20, 'About', '/about'),
|
||||
$this->makeItem(30, 'Team', '/about/team', parent: 20),
|
||||
$this->makeItem(40, 'Jobs', '/about/jobs', parent: 20),
|
||||
];
|
||||
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertCount(2, $tree);
|
||||
$this->assertSame('About', $tree[1]['title']);
|
||||
$this->assertCount(2, $tree[1]['children']);
|
||||
$this->assertSame('Team', $tree[1]['children'][0]['title']);
|
||||
$this->assertSame('Jobs', $tree[1]['children'][1]['title']);
|
||||
}
|
||||
|
||||
public function testBuildTreeWithMultipleParents(): void
|
||||
{
|
||||
$items = [
|
||||
$this->makeItem(1, 'A', '/a'),
|
||||
$this->makeItem(2, 'B', '/b'),
|
||||
$this->makeItem(3, 'A1', '/a/1', parent: 1),
|
||||
$this->makeItem(4, 'B1', '/b/1', parent: 2),
|
||||
];
|
||||
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertCount(2, $tree);
|
||||
$this->assertCount(1, $tree[0]['children']);
|
||||
$this->assertCount(1, $tree[1]['children']);
|
||||
$this->assertSame('A1', $tree[0]['children'][0]['title']);
|
||||
$this->assertSame('B1', $tree[1]['children'][0]['title']);
|
||||
}
|
||||
|
||||
public function testBuildTreeOrphansAreDropped(): void
|
||||
{
|
||||
$items = [
|
||||
$this->makeItem(1, 'Root', '/'),
|
||||
$this->makeItem(2, 'Orphan', '/orphan', parent: 999),
|
||||
];
|
||||
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertCount(1, $tree);
|
||||
$this->assertSame('Root', $tree[0]['title']);
|
||||
}
|
||||
|
||||
public function testBuildTreeNodeStructure(): void
|
||||
{
|
||||
$items = [$this->makeItem(42, 'Page', '/page', target: '_blank', classes: ['menu-item', 'custom'])];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$node = $tree[0];
|
||||
$this->assertSame(42, $node['id']);
|
||||
$this->assertSame('Page', $node['title']);
|
||||
$this->assertSame('/page', $node['url']);
|
||||
$this->assertSame('_blank', $node['target']);
|
||||
$this->assertSame('menu-item custom', $node['classes']);
|
||||
$this->assertIsBool($node['active']);
|
||||
$this->assertIsArray($node['children']);
|
||||
}
|
||||
|
||||
public function testBuildTreeClassesJoined(): void
|
||||
{
|
||||
$items = [$this->makeItem(1, 'X', '/x', classes: ['nav-item', '', 'menu-item'])];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
// Empty strings are filtered out by array_filter.
|
||||
$this->assertSame('nav-item menu-item', $tree[0]['classes']);
|
||||
}
|
||||
|
||||
public function testBuildTreeIndexIsReset(): void
|
||||
{
|
||||
$items = [
|
||||
$this->makeItem(50, 'A', '/a'),
|
||||
$this->makeItem(100, 'B', '/b'),
|
||||
];
|
||||
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
// array_values resets keys to 0-indexed.
|
||||
$this->assertArrayHasKey(0, $tree);
|
||||
$this->assertArrayHasKey(1, $tree);
|
||||
$this->assertArrayNotHasKey(50, $tree);
|
||||
}
|
||||
|
||||
// ── Active detection ────────────────────────────────────────
|
||||
|
||||
public function testBuildTreeSetsActiveViaCurrentMenuItem(): void
|
||||
{
|
||||
$items = [$this->makeItem(1, 'Active', '/active', classes: ['current-menu-item'])];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertTrue($tree[0]['active']);
|
||||
}
|
||||
|
||||
public function testBuildTreeSetsActiveViaAncestor(): void
|
||||
{
|
||||
$items = [$this->makeItem(1, 'Parent', '/parent', classes: ['current-menu-ancestor'])];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertTrue($tree[0]['active']);
|
||||
}
|
||||
|
||||
public function testBuildTreeSetsActiveViaIsPage(): void
|
||||
{
|
||||
Functions\when('is_page')->alias(fn(int $id): bool => $id === 42);
|
||||
|
||||
$items = [$this->makeItem(1, 'Contact', '/contact', object: 'page', objectId: 42)];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertTrue($tree[0]['active']);
|
||||
}
|
||||
|
||||
public function testBuildTreeSetsActiveViaIsCategory(): void
|
||||
{
|
||||
Functions\when('is_category')->alias(fn(int $id): bool => $id === 7);
|
||||
|
||||
$items = [$this->makeItem(1, 'News', '/news', object: 'category', objectId: 7)];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertTrue($tree[0]['active']);
|
||||
}
|
||||
|
||||
public function testBuildTreeNotActive(): void
|
||||
{
|
||||
$items = [$this->makeItem(1, 'Inactive', '/inactive')];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertFalse($tree[0]['active']);
|
||||
}
|
||||
|
||||
// ── Helper ──────────────────────────────────────────────────
|
||||
|
||||
private function makeItem(
|
||||
int $id,
|
||||
string $title,
|
||||
string $url,
|
||||
int $parent = 0,
|
||||
string $target = '',
|
||||
array $classes = [],
|
||||
string $object = 'page',
|
||||
int $objectId = 0,
|
||||
): object {
|
||||
return (object) [
|
||||
'ID' => $id,
|
||||
'title' => $title,
|
||||
'url' => $url,
|
||||
'target' => $target,
|
||||
'classes' => $classes,
|
||||
'menu_item_parent' => $parent,
|
||||
'object' => $object,
|
||||
'object_id' => $objectId ?: $id,
|
||||
];
|
||||
}
|
||||
}
|
||||
145
tests/Unit/Template/TemplateControllerTest.php
Normal file
145
tests/Unit/Template/TemplateControllerTest.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPBootstrap\Tests\Unit\Template;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use WPBootstrap\Template\TemplateController;
|
||||
use ReflectionMethod;
|
||||
|
||||
class TemplateControllerTest extends TestCase
|
||||
{
|
||||
private TemplateController $controller;
|
||||
private ReflectionMethod $resolveTemplate;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// TemplateController constructor: new ContextBuilder() + add_action().
|
||||
// ContextBuilder constructor: new NavWalker() (no WP calls).
|
||||
Functions\when('is_admin')->justReturn(true);
|
||||
Functions\when('wp_doing_ajax')->justReturn(false);
|
||||
Functions\when('add_action')->justReturn(true);
|
||||
|
||||
// Default: all WP conditionals return false.
|
||||
Functions\when('is_404')->justReturn(false);
|
||||
Functions\when('is_search')->justReturn(false);
|
||||
Functions\when('is_singular')->justReturn(false);
|
||||
Functions\when('is_page')->justReturn(false);
|
||||
Functions\when('is_archive')->justReturn(false);
|
||||
Functions\when('is_home')->justReturn(false);
|
||||
Functions\when('is_category')->justReturn(false);
|
||||
Functions\when('get_page_template_slug')->justReturn('');
|
||||
|
||||
$this->controller = new TemplateController();
|
||||
|
||||
$this->resolveTemplate = new ReflectionMethod($this->controller, 'resolveTemplate');
|
||||
$this->resolveTemplate->setAccessible(true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function resolve(): ?string
|
||||
{
|
||||
return $this->resolveTemplate->invoke($this->controller);
|
||||
}
|
||||
|
||||
// ── Template resolution ─────────────────────────────────────
|
||||
|
||||
public function testResolveTemplate404(): void
|
||||
{
|
||||
Functions\when('is_404')->justReturn(true);
|
||||
|
||||
$this->assertSame('pages/404.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateSearch(): void
|
||||
{
|
||||
Functions\when('is_search')->justReturn(true);
|
||||
|
||||
$this->assertSame('pages/search.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateSinglePostDefault(): void
|
||||
{
|
||||
Functions\when('is_singular')->alias(fn($type = '') => $type === 'post');
|
||||
|
||||
$this->assertSame('pages/single-sidebar.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateSinglePostFullWidth(): void
|
||||
{
|
||||
Functions\when('is_singular')->alias(fn($type = '') => $type === 'post');
|
||||
Functions\when('get_page_template_slug')->justReturn('page-full-width');
|
||||
|
||||
$this->assertSame('pages/single.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplatePageDefault(): void
|
||||
{
|
||||
Functions\when('is_page')->justReturn(true);
|
||||
|
||||
$this->assertSame('pages/page.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplatePageLanding(): void
|
||||
{
|
||||
Functions\when('is_page')->justReturn(true);
|
||||
Functions\when('get_page_template_slug')->justReturn('page-landing');
|
||||
|
||||
$this->assertSame('pages/landing.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplatePageFullWidth(): void
|
||||
{
|
||||
Functions\when('is_page')->justReturn(true);
|
||||
Functions\when('get_page_template_slug')->justReturn('page-full-width');
|
||||
|
||||
$this->assertSame('pages/full-width.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplatePageHero(): void
|
||||
{
|
||||
Functions\when('is_page')->justReturn(true);
|
||||
Functions\when('get_page_template_slug')->justReturn('page-hero');
|
||||
|
||||
$this->assertSame('pages/hero.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplatePageSidebar(): void
|
||||
{
|
||||
Functions\when('is_page')->justReturn(true);
|
||||
Functions\when('get_page_template_slug')->justReturn('page-sidebar');
|
||||
|
||||
$this->assertSame('pages/page-sidebar.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateArchive(): void
|
||||
{
|
||||
Functions\when('is_archive')->justReturn(true);
|
||||
|
||||
$this->assertSame('pages/archive.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateHome(): void
|
||||
{
|
||||
Functions\when('is_home')->justReturn(true);
|
||||
|
||||
$this->assertSame('pages/index.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateFallback(): void
|
||||
{
|
||||
// All conditionals already return false in setUp.
|
||||
$this->assertSame('pages/index.html.twig', $this->resolve());
|
||||
}
|
||||
}
|
||||
15
tests/bootstrap.php
Normal file
15
tests/bootstrap.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* PHPUnit bootstrap file.
|
||||
*
|
||||
* Loads Composer autoloader and WordPress class stubs
|
||||
* required for unit testing outside of WordPress.
|
||||
*/
|
||||
|
||||
// Composer autoloader (loads WPBootstrap\* classes + Brain\Monkey).
|
||||
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
// WordPress class stubs (global namespace).
|
||||
require_once __DIR__ . '/Stubs/WpHtmlTagProcessor.php';
|
||||
require_once __DIR__ . '/Stubs/WpBlock.php';
|
||||
require_once __DIR__ . '/Stubs/WpWidget.php';
|
||||
Reference in New Issue
Block a user