Add PHPUnit test suite with 64 unit tests (v1.1.1)
Some checks failed
Create Release Package / PHP Lint (push) Successful in 1m6s
Create Release Package / PHPUnit Tests (push) Successful in 1m4s
Create Release Package / Build Release (push) Failing after 1m13s

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:
2026-03-01 00:08:34 +01:00
parent 3165e60639
commit e607382e11
18 changed files with 3234 additions and 12 deletions

10
tests/Stubs/WpBlock.php Normal file
View 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
{
}

View 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
View 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
{
}

View 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') ?? '') : '';
}
}

View 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>',
],
];
}
}

View 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,
];
}
}

View 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
View 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';