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

View File

@@ -24,10 +24,31 @@ jobs:
run: | run: |
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 php -l find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 php -l
test:
name: PHPUnit Tests
runs-on: ubuntu-latest
needs: [lint]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, dom
tools: composer:v2
- name: Install Composer dependencies
run: composer install --no-interaction
- name: Run PHPUnit
run: composer exec -- phpunit
build-release: build-release:
name: Build Release name: Build Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [lint] needs: [test]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -120,6 +141,9 @@ jobs:
-x "${THEME_NAME}/*.log" \ -x "${THEME_NAME}/*.log" \
-x "${THEME_NAME}/*.po~" \ -x "${THEME_NAME}/*.po~" \
-x "${THEME_NAME}/*.bak" \ -x "${THEME_NAME}/*.bak" \
-x "${THEME_NAME}/tests/*" \
-x "${THEME_NAME}/phpunit.xml.dist" \
-x "${THEME_NAME}/.phpunit.cache/*" \
-x "${THEME_NAME}/views/.gitkeep" \ -x "${THEME_NAME}/views/.gitkeep" \
-x "${THEME_NAME}/assets/images/.gitkeep" \ -x "${THEME_NAME}/assets/images/.gitkeep" \
-x "*.DS_Store" -x "*.DS_Store"
@@ -187,6 +211,14 @@ jobs:
echo "src/ excluded: OK" echo "src/ excluded: OK"
fi fi
# Verify tests excluded
if unzip -l "releases/${THEME_NAME}-${VERSION}.zip" | grep -q "${THEME_NAME}/tests/"; then
echo "WARNING: tests/ directory should be excluded"
exit 1
else
echo "tests/ excluded: OK"
fi
- name: Extract changelog for release notes - name: Extract changelog for release notes
id: changelog id: changelog
run: | run: |

3
.gitignore vendored
View File

@@ -28,5 +28,8 @@ npm-debug.log
# Claude local settings # Claude local settings
.claude/settings.local.json .claude/settings.local.json
# PHPUnit cache
.phpunit.cache/
# Build artifacts (releases directory) # Build artifacts (releases directory)
releases/ releases/

View File

@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [1.1.1] - 2026-02-28
### Added
- **PHPUnit test suite**: 64 unit tests covering `BlockRenderer`, `WidgetRenderer`, `NavWalker`, and `TemplateController` classes with 107 assertions. Uses PHPUnit 11 and Brain\Monkey for WordPress function mocking.
- **Test infrastructure**: `WP_HTML_Tag_Processor` functional stub using DOMDocument for testing block renderer HTML manipulation outside WordPress. Empty stubs for `WP_Block` and `WP_Widget` type hints.
- **Build pipeline integration**: Tests run automatically before every `npm run build` via `prebuild` hook (`composer exec -- phpunit`).
- **CI test job**: New PHPUnit test step in Gitea CI workflow between lint and build-release. Tests must pass before release packages are built.
- **Release package exclusions**: `tests/`, `phpunit.xml.dist`, and `.phpunit.cache/` excluded from release ZIP packages with verification step.
## [1.1.0] - 2026-02-28 ## [1.1.0] - 2026-02-28
### Added ### Added

View File

@@ -34,7 +34,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. **Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
Current version is **v1.1.0**. See `PLAN.md` for details. Current version is **v1.1.1**. See `PLAN.md` for details.
## Technical Stack ## Technical Stack
@@ -234,6 +234,57 @@ Build steps (in order):
## Session History ## Session History
### Session 20 — v1.1.1 PHPUnit Test Suite (2026-02-28)
**Completed:** PHPUnit test suite with 64 unit tests and 107 assertions, CI integration, and build pipeline gating.
**What was built:**
- **PHPUnit 11 + Brain\Monkey 2.7**: Added as `require-dev` with PSR-4 `autoload-dev` mapping (`WPBootstrap\Tests\``tests/`).
- **WP_HTML_Tag_Processor stub** (`tests/Stubs/WpHtmlTagProcessor.php`): Functional DOMDocument-based replacement supporting `next_tag()` (by tag name or class_name), `add_class()` (idempotent), and `get_updated_html()`. Uses full HTML document wrapping for reliable body extraction.
- **BlockRendererTest** (28 tests): All 8 render methods — table classes, striped tables, button variants (default/bg/outline/gradient/unknown), button group flex, img-fluid, search input-group, blockquote+cite, pullquote, list-group, empty content returns.
- **WidgetRendererTest** (9 tests): Card structure, title heading, widget ID, type class extraction, generic filtering, h2→h4 regex, multiple h2 elements, empty content.
- **NavWalkerTest** (14 tests): Tree building (empty, single, flat, nested, multi-parent, orphans), node structure, classes, index reset, active detection (current-menu-item, ancestor, is_page, is_category, inactive).
- **TemplateControllerTest** (12 tests): Template resolution via ReflectionMethod for all page types (404, search, post default/full-width, page default/landing/full-width/hero/sidebar, archive, home, fallback).
- **Build pipeline**: `npm run test` and `prebuild` hook gate `npm run build` on passing tests.
- **CI workflow**: New `test` job between `lint` and `build-release` with PHP 8.3 + Composer.
- **Release exclusions**: `tests/`, `phpunit.xml.dist`, `.phpunit.cache/*` excluded from ZIP with verification step.
**Architecture decisions:**
- **Brain\Monkey over full WordPress bootstrap**: WordPress function mocking via `Functions\when()->justReturn()` and `Functions\when()->alias()` enables fast, isolated unit tests without a running WordPress installation.
- **DOMDocument stub over regex**: `WP_HTML_Tag_Processor` stub uses `DOMDocument`/`DOMXPath` for reliable HTML parsing. Full document wrapping (`<!DOCTYPE><html><body>...`) required because `LIBXML_HTML_NOIMPLIED` prevents `<body>` creation in PHP 8.4, breaking `getElementsByTagName('body')`.
- **`is_admin()=true` constructor bypass**: BlockRenderer/WidgetRenderer constructors register WordPress filters. Mocking `is_admin()` to return true causes early exit, enabling direct method testing.
- **ReflectionMethod for private methods**: `TemplateController::resolveTemplate()` is private. Testing via reflection avoids refactoring production code for testability.
- **ContextBuilder and TwigService skipped**: Too many WordPress dependencies for practical unit testing — better suited for integration tests.
**Key findings:**
- `LIBXML_HTML_NOIMPLIED` with `DOMDocument::loadHTML()` on PHP 8.4 does not create a `<body>` element, causing `getElementsByTagName('body')->item(0)` to return null. Solution: wrap input in full HTML document structure.
- Brain\Monkey's `Functions\when()->alias()` supports argument-dependent returns for functions like `is_singular()` that behave differently based on post type argument.
- `spl_object_id()` used in the stub's visited-node tracking enables sequential `next_tag()` advancement matching WordPress's forward-only API.
**Files created:**
- `phpunit.xml.dist` — PHPUnit configuration
- `tests/bootstrap.php` — Autoloader + stub loading
- `tests/Stubs/WpHtmlTagProcessor.php` — Functional DOMDocument-based stub
- `tests/Stubs/WpBlock.php` — Empty class stub
- `tests/Stubs/WpWidget.php` — Empty class stub
- `tests/Unit/Block/BlockRendererTest.php` — 28 tests
- `tests/Unit/Block/WidgetRendererTest.php` — 9 tests
- `tests/Unit/Template/NavWalkerTest.php` — 14 tests
- `tests/Unit/Template/TemplateControllerTest.php` — 12 tests
**Files modified:**
- `composer.json``require-dev`, `autoload-dev`
- `package.json``test`, `prebuild` scripts
- `.gitea/workflows/release.yml` — test job, exclusions, verification
- `.gitignore``.phpunit.cache/`
- `style.css` — version bump to 1.1.1
- `CHANGELOG.md`, `README.md`, `CLAUDE.md` — documentation
### Session 19 — v1.1.0 Block Renderer, Widget Renderer & Sidebar Post Layout (2026-02-28) ### Session 19 — v1.1.0 Block Renderer, Widget Renderer & Sidebar Post Layout (2026-02-28)
**Completed:** Bootstrap 5 class injection for core blocks, sidebar widget card wrappers, widget SCSS styling, and sidebar-default post template. **Completed:** Bootstrap 5 class injection for core blocks, sidebar widget card wrappers, widget SCSS styling, and sidebar-default post template.

View File

@@ -62,20 +62,41 @@ Activate the theme in **Appearance > Themes** in the WordPress admin.
| Command | Description | | Command | Description |
| --- | --- | | --- | --- |
| `npm run build` | Full production build (copy JS, compile SCSS, minify CSS) | | `npm run build` | Full production build (runs tests, copies JS, compiles SCSS, minifies CSS) |
| `npm run test` | Run PHPUnit test suite |
| `npm run dev` | Watch SCSS files and recompile on changes | | `npm run dev` | Watch SCSS files and recompile on changes |
| `npm run scss` | Compile SCSS only | | `npm run scss` | Compile SCSS only |
| `npm run postcss` | Minify CSS with Autoprefixer and cssnano | | `npm run postcss` | Minify CSS with Autoprefixer and cssnano |
| `composer install` | Install PHP dependencies (Twig) | | `composer install` | Install PHP dependencies (Twig) |
### Testing
The theme includes a PHPUnit test suite with 64 unit tests and 107 assertions covering the core PHP classes:
- **BlockRenderer** -- All 8 render methods (table, button, buttons, image, search, quote, pullquote, list)
- **WidgetRenderer** -- Card wrapping, heading downgrade, class extraction
- **NavWalker** -- Tree building, active item detection, orphan handling
- **TemplateController** -- Template resolution for all page types
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) for WordPress function mocking and a functional `WP_HTML_Tag_Processor` stub.
```bash
# Run tests
composer exec -- phpunit
# Tests also run automatically before every build
npm run build
```
### Build Pipeline ### Build Pipeline
1. `copy:js` -- Copy Bootstrap JS bundle from `node_modules` to `assets/js/` 1. `test` -- Run PHPUnit test suite (automatic via `prebuild` hook)
2. `copy:theme-js` -- Copy theme JS (dark-mode.js) from `src/js/` to `assets/js/` 2. `copy:js` -- Copy Bootstrap JS bundle from `node_modules` to `assets/js/`
3. `copy:icons` -- Copy Bootstrap Icons font files (`.woff`, `.woff2`) to `assets/fonts/` 3. `copy:theme-js` -- Copy theme JS (dark-mode.js) from `src/js/` to `assets/js/`
4. `scss` -- Compile SCSS (`src/scss/`) to CSS (`assets/css/`) 4. `copy:icons` -- Copy Bootstrap Icons font files (`.woff`, `.woff2`) to `assets/fonts/`
5. `scss:rtl` -- Compile RTL stylesheet (`assets/css/rtl.css`) 5. `scss` -- Compile SCSS (`src/scss/`) to CSS (`assets/css/`)
6. `postcss` -- Autoprefixer + cssnano minification to `assets/css/style.min.css` 6. `scss:rtl` -- Compile RTL stylesheet (`assets/css/rtl.css`)
7. `postcss` -- Autoprefixer + cssnano minification to `assets/css/style.min.css`
## Architecture ## Architecture
@@ -140,6 +161,9 @@ wp-bootstrap/
| +-- js/ Source JavaScript | +-- js/ Source JavaScript
| +-- scss/ Source SCSS | +-- scss/ Source SCSS
+-- styles/ Style variations (JSON) +-- styles/ Style variations (JSON)
+-- tests/
| +-- Stubs/ WordPress class stubs for testing
| +-- Unit/ PHPUnit test cases
+-- templates/ FSE templates (HTML) +-- templates/ FSE templates (HTML)
+-- views/ Twig templates (Bootstrap 5 HTML) +-- views/ Twig templates (Bootstrap 5 HTML)
| +-- base.html.twig | +-- base.html.twig
@@ -158,6 +182,7 @@ wp-bootstrap/
- **Bootstrap 5.3+** CSS & JS (served locally) - **Bootstrap 5.3+** CSS & JS (served locally)
- **Dart Sass** for SCSS compilation - **Dart Sass** for SCSS compilation
- **PostCSS** with Autoprefixer and cssnano - **PostCSS** with Autoprefixer and cssnano
- **PHPUnit 11** with Brain\Monkey for WordPress function mocking
## License ## License

View File

@@ -14,11 +14,20 @@
"php": ">=8.3", "php": ">=8.3",
"twig/twig": "^3.0" "twig/twig": "^3.0"
}, },
"require-dev": {
"brain/monkey": "^2.6",
"phpunit/phpunit": "^11.0"
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"WPBootstrap\\": "inc/" "WPBootstrap\\": "inc/"
} }
}, },
"autoload-dev": {
"psr-4": {
"WPBootstrap\\Tests\\": "tests/"
}
},
"config": { "config": {
"optimize-autoloader": true, "optimize-autoloader": true,
"sort-packages": true "sort-packages": true

2038
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,8 @@
"copy:js": "copyfiles -f node_modules/bootstrap/dist/js/bootstrap.bundle.min.js node_modules/bootstrap/dist/js/bootstrap.bundle.min.js.map assets/js/", "copy:js": "copyfiles -f node_modules/bootstrap/dist/js/bootstrap.bundle.min.js node_modules/bootstrap/dist/js/bootstrap.bundle.min.js.map assets/js/",
"copy:theme-js": "copyfiles -f src/js/dark-mode.js assets/js/", "copy:theme-js": "copyfiles -f src/js/dark-mode.js assets/js/",
"copy:icons": "copyfiles -f node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff2 assets/fonts/", "copy:icons": "copyfiles -f node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff2 assets/fonts/",
"test": "composer exec -- phpunit",
"prebuild": "npm run test",
"build": "npm run copy:js && npm run copy:theme-js && npm run copy:icons && npm run scss && npm run scss:rtl && npm run postcss", "build": "npm run copy:js && npm run copy:theme-js && npm run copy:icons && npm run scss && npm run scss:rtl && npm run postcss",
"watch": "npm run copy:js && npm run scss:watch", "watch": "npm run copy:js && npm run scss:watch",
"dev": "npm run watch" "dev": "npm run watch"

21
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>inc</directory>
</include>
</source>
</phpunit>

View File

@@ -7,7 +7,7 @@ Description: A modern WordPress Block Theme built from scratch with Bootstrap 5.
Requires at least: 6.7 Requires at least: 6.7
Tested up to: 6.7 Tested up to: 6.7
Requires PHP: 8.3 Requires PHP: 8.3
Version: 1.1.0 Version: 1.1.1
License: GNU General Public License v2 or later License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html License URI: http://www.gnu.org/licenses/gpl-2.0.html
Text Domain: wp-bootstrap Text Domain: wp-bootstrap

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